diff --git a/ADMIN_LIBRARY_IMPROVEMENTS_PLAN.md b/ADMIN_LIBRARY_IMPROVEMENTS_PLAN.md deleted file mode 100644 index 0d21ac5..0000000 --- a/ADMIN_LIBRARY_IMPROVEMENTS_PLAN.md +++ /dev/null @@ -1,1229 +0,0 @@ -# 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