diff --git a/ADMIN_LIBRARY_IMPROVEMENTS_PLAN.md b/ADMIN_LIBRARY_IMPROVEMENTS_PLAN.md new file mode 100644 index 0000000..0d21ac5 --- /dev/null +++ b/ADMIN_LIBRARY_IMPROVEMENTS_PLAN.md @@ -0,0 +1,1229 @@ +# 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:** +1. Toggle logic bug - clicking "Folders" again hides instead of refreshing +2. 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 to `apiDelete()` +- `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` - Add `BrowseDirectories()` function +- `internal/handlers/library.go` - Add `BrowseDirectories` handler +- `internal/router/library.go` - Register route +- `internal/services/library_service_test.go` - Unit tests +- `cmd/server/tests/library_browse_test.go` - Integration tests +- `bruno/library/browse-folders.yml` - Bruno test (manual API contract verification) +- `docs/developer/api/libraries/browse-folders.md` - API documentation +- `docs/user/admin-library-management.md` - User documentation + +**Frontend:** +- `web/src/library.ts` - Add folder browser functions +- `templates/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` - Implement `editLibrary()`, modify form handler +- `templates/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:** +```typescript +async function apiDelete(url: string): Promise { + return fetch(`/api${url}`, { + method: 'DELETE', + headers: { + 'Authorization': getAuthHeader() + } + }); +} +``` + +**Replace With:** +```typescript +async function apiDelete(url: string, data?: T): Promise { + 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 `` constraint ensures that: +1. Only objects can be passed (not primitives like strings/numbers) +2. TypeScript infers the literal type from the call site +3. If we wanted explicit type definitions, we could define: + ```typescript + interface DeleteFolderRequest { + folder_path: string; + } + ``` + And call with explicit type: `apiDelete(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:** +```typescript +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: +```typescript +async function showLibraryFolders(libraryId: string): Promise { + 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) => + '
' + + `${escapeHtmlLocal(folder.folder_path)}` + + `' + + '
' + ).join(''); + + // Add folder input with browse button (Issue 2 will add Browse button) + container.innerHTML += '
' + + `' + + `' + + '
'; + + 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):** +```typescript +interface DeleteFolderRequest { + folder_path: string; +} +``` + +**Current (broken):** +```typescript +async function removeLibraryFolder(libraryId: string, folderPath: string): Promise { + 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:** +```typescript +async function removeLibraryFolder(libraryId: string, folderPath: string): Promise { + if (!confirm(`Remove folder "${folderPath}" from the library?`)) { + return; + } + + try { + const response = await (window as any).api.delete( + `/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:** +```go +import ( + "os" // Add this import for os.Stat, os.IsNotExist, os.ReadDir +) +``` + +**Add Function:** +```go +// 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:** +```go +// 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):** +```go +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) + +```go +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) + +```go +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. + +```yaml +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) + +```markdown +# 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()`) + +```typescript +// 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 { + 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 = ` +
+ ${data.parent_path ? + `` + : ''} + ${escapeHtmlLocal(data.current_path)} +
+
+ `; + + if (data.directories.length === 0) { + html += '

No subdirectories

'; + } else { + data.directories.forEach(dir => { + const fullPath = data.current_path === '/' ? `/${dir}` : `${data.current_path}/${dir}`; + html += ` +
+ 📁 ${escapeHtmlLocal(dir)} +
+ `; + }); + } + + html += ` +
+
+ + +
+ `; + + 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:** + +```typescript +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:** + +```typescript +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:** +```typescript +container.innerHTML += '
' + + `' + + `' + +'
'; +``` + +**Replace With:** +```typescript +container.innerHTML += '
' + + `' + + `' + + `' + +'
'; +``` + +--- + +#### 2.11 Frontend - Folder Browser Modal + +**File:** `templates/admin_library.templ` + +**Add after Create Library Modal (after line 147):** +```html + + +``` + +--- + +### Issue 3: Edit Library Button + +#### 3.1 Implement `editLibrary()` Function + +**File:** `web/src/library.ts` (lines 278-284) + +**Current (placeholder):** +```typescript +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:** +```typescript +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:** +```typescript +async function handleCreateLibrarySubmit(event: Event): Promise { + 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:** +```typescript +async function handleCreateLibrarySubmit(event: Event): Promise { + 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:** +```typescript +function showCreateLibraryModal(): void { + const modal = document.getElementById('create-library-modal') as HTMLElement; + if (modal) { + modal.classList.remove('hidden'); + } +} +``` + +**Replace With:** +```typescript +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:** +```html +
+ + +
+``` + +--- + +### Issue 4: Delete Library Clarification + +#### 4.1 Update Confirmation Message + +**File:** `web/src/library.ts` (line 177 in `deleteLibrary()`) + +**Current:** +```typescript +if (!confirm(`Delete library "${library.name}"? This will permanently remove all associated media and cannot be undone.`)) { + return; +} +``` + +**Replace With:** +```typescript +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: + +```bash +# 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 apiDelete +- `web/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 function +- `internal/handlers/library.go` - BrowseDirectories handler +- `internal/router/library.go` - Route registration + +### Tests (Issue 2) +- `internal/services/library_service_test.go` - Unit tests for BrowseDirectories +- `cmd/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 documentation +- `docs/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:ts` compiles 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