# 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