- Document 4 improvements to /admin/library page - Issue 1: Fix folder list display bugs (toggle logic, DELETE request body) - Issue 2: Add server-side folder browser with full-stack implementation - Issue 3: Implement edit library functionality - Issue 4: Clarify delete confirmation message Key changes: - Add os import for BrowseDirectories service function - Use event delegation pattern for folder browser UI - Add DeleteFolderRequest interface for type safety - Include comprehensive testing strategy (unit, integration, Bruno) - Follow PROJECT_GUIDELINES.md throughout - Procedural/imperative style, no OOP - Progressive enhancement maintained
38 KiB
Admin Library Page Improvements - Implementation Plan
Overview
Four improvements to the /admin/library page to fix bugs and add missing functionality.
Issues Addressed
Issue 1: Folder List Display Bugs
Current State: Folders can be added but disappear after adding; delete button doesn't work.
Root Causes:
- Toggle logic bug - clicking "Folders" again hides instead of refreshing
- DELETE API missing request body - backend requires
{"folder_path": "..."}but frontend doesn't send it
Files to Modify:
web/src/api.ts- Add optional data parameter toapiDelete()web/src/library.ts- Fix toggle logic and DELETE call
Issue 2: Folder Path Browse Button
Current State: User must manually type server paths.
Solution: Add server-side folder browser (like Audiobookshelf, Jellyfin, Sonarr, etc.)
Type: Full-stack (requires backend + frontend)
Files to Modify:
Backend:
internal/services/library_service.go- AddBrowseDirectories()functioninternal/handlers/library.go- AddBrowseDirectorieshandlerinternal/router/library.go- Register routeinternal/services/library_service_test.go- Unit testscmd/server/tests/library_browse_test.go- Integration testsbruno/library/browse-folders.yml- Bruno test (manual API contract verification)docs/developer/api/libraries/browse-folders.md- API documentationdocs/user/admin-library-management.md- User documentation
Frontend:
web/src/library.ts- Add folder browser functionstemplates/admin_library.templ- Add folder browser modal + Browse button
Security:
- Path traversal protection (block
..) - Only list directories, not files
- Admin-only access
Issue 3: Edit Library Button
Current State: Edit button shows "coming soon" toast.
Solution: Reuse "Create Library" modal, populate with existing data, switch between create/edit mode.
Files to Modify:
web/src/library.ts- ImplementeditLibrary(), modify form handlertemplates/admin_library.templ- Add hidden input, data attributes to Edit button
No backend changes needed - PUT /api/libraries/:id already exists.
Issue 4: Delete Library Confirmation
Current State: Basic confirmation, doesn't clarify what gets deleted.
Solution: Improve message to explicitly state book files on disk are NOT deleted.
Files to Modify:
web/src/library.ts- Update confirmation message
Detailed Implementation
Issue 1: Folder List Bug Fixes
1.1 Modify web/src/api.ts
Location: Line 37
Current:
async function apiDelete(url: string): Promise<Response> {
return fetch(`/api${url}`, {
method: 'DELETE',
headers: {
'Authorization': getAuthHeader()
}
});
}
Replace With:
async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> {
return fetch(`/api${url}`, {
method: 'DELETE',
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : undefined
});
}
Rationale: Generic type parameter provides type safety - TypeScript will enforce that request bodies match the expected object structure (e.g., { folder_path: string }), preventing runtime errors from malformed requests. 100% backward compatible (optional parameter), only 2 callers exist in codebase.
Type Safety Note: The generic <T extends object> constraint ensures that:
- Only objects can be passed (not primitives like strings/numbers)
- TypeScript infers the literal type from the call site
- If we wanted explicit type definitions, we could define:
And call with explicit type:
interface DeleteFolderRequest { folder_path: string; }apiDelete<DeleteFolderRequest>(url, data)But inference is cleaner and equally type-safe.
1.2 Fix Toggle Logic in web/src/library.ts
Location: Lines 196-228
Current Problem:
if (container.classList.contains('hidden')) {
// load and show folders
} else {
container.classList.add('hidden'); // <-- HIDES on second click!
}
Fix: Remove toggle, always reload and show:
async function showLibraryFolders(libraryId: string): Promise<void> {
const container = document.getElementById(`library-folders-${libraryId}`);
if (!container) return;
// Always reload content
try {
const response = await (window as any).api.get(`/libraries/${libraryId}/folders`);
const folders = await (window as any).api.handleResponse(response) as LibraryFolder[];
container.innerHTML = folders.map((folder: LibraryFolder) =>
'<div class="flex justify-between items-center p-2 rounded" style="background-color: var(--bg-secondary); border-color: var(--border)">' +
`<span class="text-sm" style="color: var(--text-primary)">${escapeHtmlLocal(folder.folder_path)}</span>` +
`<button data-library-id="${libraryId}" data-folder-path="${escapeHtmlLocal(folder.folder_path)}" data-action="remove-folder" ` +
'class="text-xs text-red-500">Remove</button>' +
'</div>'
).join('');
// Add folder input with browse button (Issue 2 will add Browse button)
container.innerHTML += '<div class="mt-2 flex space-x-2">' +
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';
container.classList.remove('hidden');
} catch (error) {
(window as any).api.handleError(error, 'Failed to load folders');
}
}
1.3 Fix DELETE Request Body in removeLibraryFolder()
File: web/src/library.ts (modify removeLibraryFolder function, around line 259)
Add interface at top of file (with other interfaces around line 14):
interface DeleteFolderRequest {
folder_path: string;
}
Current (broken):
async function removeLibraryFolder(libraryId: string, folderPath: string): Promise<void> {
if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
return;
}
try {
const response = await (window as any).api.delete(`/libraries/${libraryId}/folders`);
await (window as any).api.handleVoidResponse(response);
if ((window as any).showToast?.success) {
(window as any).showToast.success('Folder removed successfully');
}
void showLibraryFolders(libraryId); // Refresh
} catch (error) {
(window as any).api.handleError(error, 'Failed to remove folder');
}
}
Replace With:
async function removeLibraryFolder(libraryId: string, folderPath: string): Promise<void> {
if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
return;
}
try {
const response = await (window as any).api.delete<DeleteFolderRequest>(
`/libraries/${libraryId}/folders`,
{ folder_path: folderPath }
);
await (window as any).api.handleVoidResponse(response);
if ((window as any).showToast?.success) {
(window as any).showToast.success('Folder removed successfully');
}
void showLibraryFolders(libraryId); // Refresh
} catch (error) {
(window as any).api.handleError(error, 'Failed to remove folder');
}
}
Rationale: Backend DeleteLibraryFolder handler (library.go:237) reuses AddLibraryFolderRequest struct and requires { "folder_path": "..." } in request body. The c.Bind() call fails with "invalid request" error when body is missing, causing folder removal to silently fail. Using explicit DeleteFolderRequest interface provides type safety and prevents passing incorrect request shapes.
Issue 2: Folder Browser (Full-Stack)
2.1 Backend - Service Layer
File: internal/services/library_service.go
Add Import:
import (
"os" // Add this import for os.Stat, os.IsNotExist, os.ReadDir
)
Add Function:
// BrowseDirectories lists directories at a given path for folder browser UI
// Returns: (directories, currentPath, parentPath, error)
func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]string, string, string, error) {
// Security: path traversal protection
if strings.Contains(path, "..") {
return nil, "", "", fmt.Errorf("path traversal not allowed")
}
cleanPath := filepath.Clean(path)
// Check if path exists and is accessible
fileInfo, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return nil, "", "", fmt.Errorf("path does not exist")
}
return nil, "", "", fmt.Errorf("path not accessible: %w", err)
}
if !fileInfo.IsDir() {
return nil, "", "", fmt.Errorf("not a directory")
}
// Read directory contents
entries, err := os.ReadDir(cleanPath)
if err != nil {
return nil, "", "", fmt.Errorf("failed to read directory: %w", err)
}
// Filter only directories
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, entry.Name())
}
}
// Get parent path for navigation
parentPath := filepath.Dir(cleanPath)
if parentPath == cleanPath {
parentPath = "" // At root
}
return dirs, cleanPath, parentPath, nil
}
2.2 Backend - Handler
File: internal/handlers/library.go
Add Handler Function:
// BrowseDirectories returns directory listings for folder browser UI
func (h *LibraryHandler) BrowseDirectories(c echo.Context) error {
path := c.QueryParam("path")
if path == "" {
path = "/" // Start from root
}
dirs, currentPath, parentPath, err := h.libraryService.BrowseDirectories(c.Request().Context(), path)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"current_path": currentPath,
"parent_path": parentPath,
"directories": dirs,
})
}
2.3 Backend - Router
File: internal/router/library.go
Add Route Registration (before line 31, before all /:id routes):
adminLibrary.GET("/browse", cfg.LibraryHandler.BrowseDirectories)
CRITICAL: Must come before /:id routes (line 31) to avoid route matching conflicts where /libraries/browse could be captured by /:id.
2.4 Backend - Unit Tests
File: internal/services/library_service_test.go (create if doesn't exist)
package services
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBrowseDirectories(t *testing.T) {
// BrowseDirectories operates on filesystem only - no database needed
service := &LibraryService{}
t.Run("blocks path traversal with ..", func(t *testing.T) {
_, _, _, err := service.BrowseDirectories(context.Background(), "/etc/../root")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal not allowed")
})
t.Run("returns error for non-existent path", func(t *testing.T) {
_, _, _, err := service.BrowseDirectories(context.Background(), "/nonexistent/path")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path does not exist")
})
t.Run("returns error when path is a file", func(t *testing.T) {
_, _, _, err := service.BrowseDirectories(context.Background(), "/etc/passwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a directory")
})
t.Run("returns subdirectories for valid path", func(t *testing.T) {
dirs, currentPath, parentPath, err := service.BrowseDirectories(context.Background(), "/tmp")
assert.NoError(t, err)
assert.NotEmpty(t, dirs)
assert.Equal(t, "/tmp", currentPath)
assert.Equal(t, "/", parentPath)
})
}
2.5 Backend - Integration Tests
File: cmd/server/tests/library_browse_test.go (create new file)
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
setup := setupTestServer(t)
t.Run("GET /api/libraries/browse - no authentication returns 401", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("GET /api/libraries/browse - regular user returns 403 forbidden", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var data struct {
CurrentPath string `json:"current_path"`
ParentPath string `json:"parent_path"`
Directories []string `json:"directories"`
}
err := json.Unmarshal(rec.Body.Bytes(), &data)
assert.NoError(t, err)
assert.Equal(t, "/tmp", data.CurrentPath)
assert.NotEmpty(t, data.Directories)
})
t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/etc/../root", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
var errResp struct {
Error string `json:"error"`
}
json.Unmarshal(rec.Body.Bytes(), &errResp)
assert.Contains(t, errResp.Error, "path traversal not allowed")
})
}
2.6 Bruno API Test (Manual Testing)
File: bruno/library/browse-folders.yml (create new file)
Purpose: Single manual test case for API contract verification and examples. Unit and integration tests cover all scenarios (no auth, user, admin, edge cases). Bruno provides a quick manual test for API contract validation.
meta:
name: Browse library folders
type: http
seq: 1
config:
test:
filter: "response.status == 200"
assertions:
- type: stddev
target: response_time
threshold: 1000
- type: jsonpath
expression: response.directories
condition: exists
- type: jsonpath
expression: response.current_path
condition: exists
- type: jsonpath
expression: response.parent_path
condition: exists
req:
method: GET
url: "{{base_url}}/api/libraries/browse?path=/tmp"
headers:
Authorization: "Bearer {{ADMIN_TOKEN}}"
2.7 User Documentation
File: docs/user/admin-library-management.md (create new file)
# Admin Library Management
## Adding Library Folders
When creating or managing a library, you can add folders containing your media files (ebooks, comics, manga).
### Using the Folder Browser
The admin library page includes a folder browser to help you select folders on the server:
1. Navigate to **Admin → Library Management**
2. Find the library you want to manage
3. Click the **Folders** button
4. Click **Browse** next to "Add folder path"
5. Navigate through the server's filesystem
6. Select a folder by clicking **Select This Folder**
### Security
- The folder browser only shows directories (not files)
- Path traversal is protected (cannot access parent directories with `..`)
- Only admin users can browse the filesystem
### Manual Entry
Alternatively, you can manually type the full server path if you know it:
/home/user/books /media/external/ebooks /var/lib/manga
2.8 Frontend - Folder Browser Functions
File: web/src/library.ts (add before initializeLibraryAdmin())
// Folder browser state
let currentBrowsePath = '';
let currentBrowseInputId = '';
// Show folder browser modal
function showFolderBrowser(inputId: string): void {
currentBrowseInputId = inputId;
currentBrowsePath = '/'; // Start at root
const modal = document.getElementById('folder-browser-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
void loadBrowseDirectories(currentBrowsePath);
}
}
// Load directories for browsing
async function loadBrowseDirectories(path: string): Promise<void> {
try {
const response = await (window as any).api.get(`/libraries/browse?path=${encodeURIComponent(path)}`);
const data = await (window as any).api.handleResponse(response) as {
current_path: string;
parent_path: string;
directories: string[];
};
currentBrowsePath = data.current_path;
renderBrowseDirectories(data);
} catch (error) {
(window as any).api.handleError(error, 'Failed to load directories');
}
}
// Render browse directories (uses event delegation via data-action attributes)
function renderBrowseDirectories(data: { current_path: string; parent_path: string; directories: string[] }): void {
const container = document.getElementById('folder-browser-content');
if (!container) return;
let html = `
<div class="flex items-center gap-2 mb-4">
${data.parent_path ?
`<button type="button" data-action="browse-parent" data-path="${escapeHtmlLocal(data.parent_path)}" class="btn-secondary px-3 py-1 rounded">↑ Parent</button>`
: ''}
<span class="text-sm" style="color: var(--text-secondary)">${escapeHtmlLocal(data.current_path)}</span>
</div>
<div class="max-h-64 overflow-y-auto space-y-1">
`;
if (data.directories.length === 0) {
html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
} else {
data.directories.forEach(dir => {
const fullPath = data.current_path === '/' ? `/${dir}` : `${data.current_path}/${dir}`;
html += `
<div class="p-2 rounded cursor-pointer hover:opacity-80"
style="background-color: var(--bg-secondary); color: var(--text-primary)"
data-action="browse-navigate"
data-path="${escapeHtmlLocal(fullPath)}">
📁 ${escapeHtmlLocal(dir)}
</div>
`;
});
}
html += `
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" data-action="browse-cancel" class="btn-secondary px-4 py-2 rounded">Cancel</button>
<button type="button" data-action="browse-select" data-path="${escapeHtmlLocal(data.current_path)}" class="btn-primary px-4 py-2 rounded">Select This Folder</button>
</div>
`;
container.innerHTML = html;
}
// Navigate to subdirectory
function navigateFolderBrowser(path: string): void {
void loadBrowseDirectories(path);
}
// Select folder and close browser
function selectBrowseFolder(path: string): void {
const input = document.getElementById(currentBrowseInputId) as HTMLInputElement;
if (input) {
input.value = path;
}
hideFolderBrowser();
}
// Hide folder browser modal
function hideFolderBrowser(): void {
const modal = document.getElementById('folder-browser-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
}
**NOTE:** Export the new folder browser functions by adding them to the existing window exports block at the end of library.ts (around line 380-387), not as a separate block:
```typescript
// Add these to the existing exports at lines 380-387:
(window as any).showFolderBrowser = showFolderBrowser;
(window as any).navigateFolderBrowser = navigateFolderBrowser;
(window as any).selectBrowseFolder = selectBrowseFolder;
(window as any).hideFolderBrowser = hideFolderBrowser;
2.9 Frontend - Update Event Delegation Handler
File: web/src/library.ts (modify handleLibraryListClick function, around line 309)
Add cases for folder browser actions:
function handleLibraryListClick(event: Event): void {
const target = event.target as HTMLElement;
const button = target.closest('button') as HTMLElement;
if (!button) return;
const action = button.dataset.action;
const libraryId = button.dataset.libraryId;
switch (action) {
case 'show-folders':
if (libraryId) showLibraryFolders(libraryId);
break;
case 'delete':
if (libraryId) deleteLibrary(libraryId);
break;
case 'edit':
if (libraryId) editLibrary(libraryId);
break;
case 'add-folder':
if (libraryId) addLibraryFolder(libraryId);
break;
case 'remove-folder':
if (libraryId && button.dataset.folderPath) {
removeLibraryFolder(libraryId, button.dataset.folderPath);
}
break;
case 'browse-folder':
if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId);
break;
}
}
function handleFolderBrowserClick(event: Event): void {
const target = event.target as HTMLElement;
const button = target.closest('button') as HTMLElement;
const div = target.closest('div[data-action]') as HTMLElement;
// Handle button clicks
if (button) {
const action = button.dataset.action;
const path = button.dataset.path;
switch (action) {
case 'browse-parent':
if (path) navigateFolderBrowser(path);
break;
case 'browse-cancel':
hideFolderBrowser();
break;
case 'browse-select':
if (path) selectBrowseFolder(path);
break;
}
}
// Handle directory div clicks
if (div && div.dataset.action === 'browse-navigate') {
const path = div.dataset.path;
if (path) navigateFolderBrowser(path);
}
}
Update initializeLibraryAdmin to add folder browser event listener:
function initializeLibraryAdmin(): void {
// Setup event listeners
const librariesList = document.getElementById('libraries-list');
if (librariesList) {
librariesList.addEventListener('click', handleLibraryListClick);
}
const folderBrowserModal = document.getElementById('folder-browser-modal');
if (folderBrowserModal) {
folderBrowserModal.addEventListener('click', handleFolderBrowserClick);
}
document.addEventListener('click', handleGlobalClick);
// ... rest of existing code
}
2.10 Frontend - Add Browse Button
File: web/src/library.ts (modify showLibraryFolders function, around line 214)
Current folder input:
container.innerHTML += '<div class="mt-2 flex space-x-2">' +
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';
Replace With:
container.innerHTML += '<div class="mt-2 flex space-x-2">' +
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
`<button data-action="browse-folder" data-input-id="folder-path-${libraryId}" ` +
'class="btn-secondary px-2 py-1 text-xs rounded">Browse</button>' +
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';
2.11 Frontend - Folder Browser Modal
File: templates/admin_library.templ
Add after Create Library Modal (after line 147):
<!-- Folder Browser Modal -->
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
<button type="button" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
</div>
<div id="folder-browser-content">
<!-- Directory listings will be rendered here -->
</div>
</div>
</div>
Issue 3: Edit Library Button
3.1 Implement editLibrary() Function
File: web/src/library.ts (lines 278-284)
Current (placeholder):
function editLibrary(libraryId: string): void {
console.log('Edit library:', libraryId);
if ((window as any).showToast?.info) {
(window as any).showToast.info('Edit library functionality coming soon');
}
}
Replace With:
function editLibrary(libraryId: string): void {
const library = libraries.find(l => l.id === libraryId);
if (!library) {
if ((window as any).showToast?.error) {
(window as any).showToast.error('Library not found');
}
return;
}
// Populate form with existing data
const form = document.getElementById('create-library-form') as HTMLFormElement;
if (form) {
const nameInput = form.querySelector('[name="name"]') as HTMLInputElement;
const descInput = form.querySelector('[name="description"]') as HTMLTextAreaElement;
const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement;
if (nameInput) nameInput.value = library.name;
if (descInput) descInput.value = library.description || '';
if (typeInput) typeInput.value = library.library_type_id;
}
// Update modal title
const modalTitle = document.querySelector('#create-library-modal h2');
if (modalTitle) {
modalTitle.textContent = 'Edit Library';
}
// Store library ID for form submission
const libraryIdInput = document.getElementById('library-id') as HTMLInputElement;
if (libraryIdInput) {
libraryIdInput.value = libraryId;
}
showCreateLibraryModal();
}
3.2 Modify Form Handler
File: web/src/library.ts (lines 144-170)
Current:
async function handleCreateLibrarySubmit(event: Event): Promise<void> {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const libraryData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
type: formData.get('type') as string
};
try {
const response = await (window as any).api.post('/libraries', libraryData);
await (window as any).api.handleResponse(response) as { data: Library };
if ((window as any).showToast?.success) {
(window as any).showToast.success('Library created successfully');
}
hideCreateLibraryModal();
form.reset();
void reloadLibraries();
} catch (error) {
(window as any).api.handleError(error, 'Failed to create library');
}
}
Replace With:
async function handleCreateLibrarySubmit(event: Event): Promise<void> {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const libraryId = (document.getElementById('library-id') as HTMLInputElement)?.value;
const isEdit = !!libraryId;
const libraryData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
type: formData.get('type') as string
};
try {
const url = isEdit ? `/libraries/${libraryId}` : '/libraries';
const method = isEdit ? 'put' : 'post';
const response = await (window as any).api[method](url, libraryData);
if (isEdit) {
await (window as any).api.handleVoidResponse(response);
} else {
await (window as any).api.handleResponse(response) as { data: Library };
}
if ((window as any).showToast?.success) {
(window as any).showToast.success(isEdit ? 'Library updated successfully' : 'Library created successfully');
}
hideCreateLibraryModal();
form.reset();
// Clear library ID
const libraryIdInput = document.getElementById('library-id') as HTMLInputElement;
if (libraryIdInput) {
libraryIdInput.value = '';
}
void reloadLibraries();
} catch (error) {
(window as any).api.handleError(error, isEdit ? 'Failed to update library' : 'Failed to create library');
}
}
3.3 Update Modal Title Reset
File: web/src/library.ts (lines 287-292)
Current:
function showCreateLibraryModal(): void {
const modal = document.getElementById('create-library-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
}
Replace With:
function showCreateLibraryModal(): void {
const modal = document.getElementById('create-library-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
// Reset modal title to "Create Library"
const modalTitle = document.querySelector('#create-library-modal h2');
if (modalTitle) {
modalTitle.textContent = 'Create Library';
}
}
}
3.4 Add Hidden Input to Template
File: templates/admin_library.templ (line 124, in form)
Add at beginning of form:
<form id="create-library-form">
<input type="hidden" id="library-id" name="id">
<!-- rest of form unchanged -->
</form>
Issue 4: Delete Library Clarification
4.1 Update Confirmation Message
File: web/src/library.ts (line 177 in deleteLibrary())
Current:
if (!confirm(`Delete library "${library.name}"? This will permanently remove all associated media and cannot be undone.`)) {
return;
}
Replace With:
const message = `Are you sure you want to delete "${library.name}"?
This will remove:
• Library metadata from the database
• All folder references
• All book records from the database
⚠️ Book files on disk will NOT be deleted.
This action cannot be undone.`;
if (!confirm(message)) {
return;
}
Git Commit Structure
Frontend Commits (Issues 3, 4, 1)
feat(frontend): implement library edit functionality
- Reuse Create Library modal for edit mode
- Add hidden library-id input to track create vs edit
- Update handleCreateLibrarySubmit to detect mode and use PUT vs POST
- Implement editLibrary() to populate modal with existing data
- Pass library data to Edit button via data attributes
- Reset modal title when opening for create mode
Fixes: Issue 3
---
fix(frontend): clarify library delete confirmation message
- Explicitly state book files on disk are NOT deleted
- List what gets removed (database records only)
- Improve user understanding of delete operation
- Use multi-line format for better readability
Fixes: Issue 4
---
fix(frontend): resolve folder list display bugs
- Fix toggle logic that hid folders on second click
- Remove toggle behavior, always reload and show folders
- Add data parameter support to apiDelete() in api.ts
- Fix DELETE /api/libraries/:id/folders to include request body
- 100% backward compatible (optional parameter)
Fixes: Issue 1
Full-Stack Commits (Issue 2)
feat(backend): add server-side directory browsing API
- Add BrowseDirectories() to library service with path traversal protection
- Add BrowseDirectories handler with proper error handling
- Register GET /api/libraries/browse endpoint (admin-only)
- Returns current path, parent path, and list of subdirectories
- Security: blocks "..", validates path exists, checks is directory
Fixes: Issue 2 (backend)
---
test(backend): add unit and integration tests for folder browsing
- Add unit tests in internal/services/library_service_test.go
- Test path traversal protection
- Test non-existent path handling
- Test file vs directory validation
- Test successful directory listing
- Add integration tests in cmd/server/tests/library_browse_test.go
- Use setupTestServer() helper from test_helpers.go
- Test no authentication returns 401
- Test regular user returns 403 forbidden
- Test admin can browse directories
- Test path traversal blocking
- All tests use table-driven approach with t.Run()
Fixes: Issue 2 (tests)
---
docs(api): document library folder browsing endpoint
- Add docs/developer/api/libraries/browse-folders.md
- Document security features (path traversal protection, admin-only)
- Include usage examples and error responses
- Clarify browses server filesystem, not client's
docs(user): add admin library folder browser documentation
- Add docs/user/admin-library-management.md
- Document how to use the folder browser UI
- Include security notes and manual entry instructions
- Explain server filesystem browsing behavior
Fixes: Issue 2 (documentation)
---
test(bruno): add manual API contract test for folder browsing
- Create bruno/library/browse-folders.yml
- Single manual test case for admin context
- Unit/integration tests cover all scenarios (no auth, user, admin)
- Bruno provides quick manual API contract verification
Fixes: Issue 2 (manual testing)
---
feat(frontend): add folder browser UI for library management
- Add folder browser modal to admin/library page
- Implement directory navigation in library.ts
- Add Browse button next to folder path input
- Connect to backend /api/libraries/browse endpoint
- Support parent directory navigation and path selection
Fixes: Issue 2 (frontend)
Verification Steps
After each commit:
# 1. Verify Go compilation
go build ./...
# 2. Run tests
go test ./... -v
# 3. Run verification script
bash scripts/verify-guidelines.sh
# 4. Review git diff
git diff
# 5. Rebuild container for testing
podman compose up --build -d
Testing Checklist
Issue 1: Folder List Bugs
- Click "Folders" button - folder list appears
- Click "Folders" again - list refreshes (doesn't hide)
- Add new folder - folder appears in list
- Click "Remove" on folder - confirmation dialog appears
- Confirm removal - folder removed from list
- Check browser console - no errors
Issue 2: Folder Browser
- Click "Browse" button - folder browser modal opens
- See list of directories starting at root (/)
- Click on directory - navigate into it
- Click "↑ Parent" - navigate to parent directory
- Click "Select This Folder" - path entered in input field
- Modal closes after selection
- Test path traversal protection (try URL with "..") - should error
- Test with non-existent path - should show error
- Check browser console - no errors
Issue 3: Edit Library
- Click "Edit" button on library - modal opens with library data
- Modal title shows "Edit Library"
- Form is pre-filled with existing library data
- Modify name/description/type
- Click "Create" button - library updates
- Success toast appears
- Library list refreshes with updated data
- Open modal again - shows "Create Library" title
- Create new library - still works
Issue 4: Delete Clarification
- Click "Delete" button on library
- See improved confirmation message
- Message explicitly states book files NOT deleted
- Message lists what WILL be deleted
- Cancel - nothing happens
- Confirm - library deleted, success toast shown
Files Summary
Frontend Only
web/src/api.ts- Add data parameter to apiDeleteweb/src/library.ts- All frontend logic (Issues 1, 3, 4, and part of 2)templates/admin_library.templ- HTML changes (Issues 2, 3)
Backend Only (Issue 2)
internal/services/library_service.go- BrowseDirectories functioninternal/handlers/library.go- BrowseDirectories handlerinternal/router/library.go- Route registration
Tests (Issue 2)
internal/services/library_service_test.go- Unit tests for BrowseDirectoriescmd/server/tests/library_browse_test.go- Integration tests (no auth, user, admin contexts)
Documentation (Issue 2)
docs/developer/api/libraries/browse-folders.md- API reference documentationdocs/user/admin-library-management.md- User-facing documentation for folder browser
Manual API Testing (Issue 2)
bruno/library/browse-folders.yml- Single Bruno test for manual API contract verification
Notes
- All TypeScript files are in
web/src/- these are the source files - JS files in
web/static/are compiled artifacts, not manually edited - Build process:
npm run build:tscompiles TS → JS, or use container rebuild - Following PROJECT_GUIDELINES.md throughout
- No OOP - procedural/imperative style only
- TailwindCSS only - no custom CSS
- All business logic in service layer
- Progressive enhancement - pages work without JavaScript