Files
bookhoard/ADMIN_LIBRARY_IMPROVEMENTS_PLAN.md
T
john-okeefe a137d3ea76 docs: add admin library page improvements implementation plan
- Document 4 improvements to /admin/library page
- Issue 1: Fix folder list display bugs (toggle logic, DELETE request body)
- Issue 2: Add server-side folder browser with full-stack implementation
- Issue 3: Implement edit library functionality
- Issue 4: Clarify delete confirmation message

Key changes:
- Add os import for BrowseDirectories service function
- Use event delegation pattern for folder browser UI
- Add DeleteFolderRequest interface for type safety
- Include comprehensive testing strategy (unit, integration, Bruno)
- Follow PROJECT_GUIDELINES.md throughout
- Procedural/imperative style, no OOP
- Progressive enhancement maintained
2026-02-23 16:12:25 -05:00

1230 lines
38 KiB
Markdown

# 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<Response> {
return fetch(`/api${url}`, {
method: 'DELETE',
headers: {
'Authorization': getAuthHeader()
}
});
}
```
**Replace With:**
```typescript
async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> {
return fetch(`/api${url}`, {
method: 'DELETE',
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : undefined
});
}
```
**Rationale:** Generic type parameter provides type safety - TypeScript will enforce that request bodies match the expected object structure (e.g., `{ folder_path: string }`), preventing runtime errors from malformed requests. 100% backward compatible (optional parameter), only 2 callers exist in codebase.
**Type Safety Note:** The generic `<T extends object>` constraint ensures that:
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<DeleteFolderRequest>(url, data)`
But inference is cleaner and equally type-safe.
---
#### 1.2 Fix Toggle Logic in `web/src/library.ts`
**Location:** Lines 196-228
**Current Problem:**
```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<void> {
const container = document.getElementById(`library-folders-${libraryId}`);
if (!container) return;
// Always reload content
try {
const response = await (window as any).api.get(`/libraries/${libraryId}/folders`);
const folders = await (window as any).api.handleResponse(response) as LibraryFolder[];
container.innerHTML = folders.map((folder: LibraryFolder) =>
'<div class="flex justify-between items-center p-2 rounded" style="background-color: var(--bg-secondary); border-color: var(--border)">' +
`<span class="text-sm" style="color: var(--text-primary)">${escapeHtmlLocal(folder.folder_path)}</span>` +
`<button data-library-id="${libraryId}" data-folder-path="${escapeHtmlLocal(folder.folder_path)}" data-action="remove-folder" ` +
'class="text-xs text-red-500">Remove</button>' +
'</div>'
).join('');
// Add folder input with browse button (Issue 2 will add Browse button)
container.innerHTML += '<div class="mt-2 flex space-x-2">' +
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';
container.classList.remove('hidden');
} catch (error) {
(window as any).api.handleError(error, 'Failed to load folders');
}
}
```
---
#### 1.3 Fix DELETE Request Body in `removeLibraryFolder()`
**File:** `web/src/library.ts` (modify `removeLibraryFolder` function, around line 259)
**Add interface at top of file (with other interfaces around line 14):**
```typescript
interface DeleteFolderRequest {
folder_path: string;
}
```
**Current (broken):**
```typescript
async function removeLibraryFolder(libraryId: string, folderPath: string): Promise<void> {
if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
return;
}
try {
const response = await (window as any).api.delete(`/libraries/${libraryId}/folders`);
await (window as any).api.handleVoidResponse(response);
if ((window as any).showToast?.success) {
(window as any).showToast.success('Folder removed successfully');
}
void showLibraryFolders(libraryId); // Refresh
} catch (error) {
(window as any).api.handleError(error, 'Failed to remove folder');
}
}
```
**Replace With:**
```typescript
async function removeLibraryFolder(libraryId: string, folderPath: string): Promise<void> {
if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
return;
}
try {
const response = await (window as any).api.delete<DeleteFolderRequest>(
`/libraries/${libraryId}/folders`,
{ folder_path: folderPath }
);
await (window as any).api.handleVoidResponse(response);
if ((window as any).showToast?.success) {
(window as any).showToast.success('Folder removed successfully');
}
void showLibraryFolders(libraryId); // Refresh
} catch (error) {
(window as any).api.handleError(error, 'Failed to remove folder');
}
}
```
**Rationale:** Backend `DeleteLibraryFolder` handler (library.go:237) reuses `AddLibraryFolderRequest` struct and requires `{ "folder_path": "..." }` in request body. The `c.Bind()` call fails with "invalid request" error when body is missing, causing folder removal to silently fail. Using explicit `DeleteFolderRequest` interface provides type safety and prevents passing incorrect request shapes.
---
### Issue 2: Folder Browser (Full-Stack)
#### 2.1 Backend - Service Layer
**File:** `internal/services/library_service.go`
**Add Import:**
```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<void> {
try {
const response = await (window as any).api.get(`/libraries/browse?path=${encodeURIComponent(path)}`);
const data = await (window as any).api.handleResponse(response) as {
current_path: string;
parent_path: string;
directories: string[];
};
currentBrowsePath = data.current_path;
renderBrowseDirectories(data);
} catch (error) {
(window as any).api.handleError(error, 'Failed to load directories');
}
}
// Render browse directories (uses event delegation via data-action attributes)
function renderBrowseDirectories(data: { current_path: string; parent_path: string; directories: string[] }): void {
const container = document.getElementById('folder-browser-content');
if (!container) return;
let html = `
<div class="flex items-center gap-2 mb-4">
${data.parent_path ?
`<button type="button" data-action="browse-parent" data-path="${escapeHtmlLocal(data.parent_path)}" class="btn-secondary px-3 py-1 rounded">↑ Parent</button>`
: ''}
<span class="text-sm" style="color: var(--text-secondary)">${escapeHtmlLocal(data.current_path)}</span>
</div>
<div class="max-h-64 overflow-y-auto space-y-1">
`;
if (data.directories.length === 0) {
html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
} else {
data.directories.forEach(dir => {
const fullPath = data.current_path === '/' ? `/${dir}` : `${data.current_path}/${dir}`;
html += `
<div class="p-2 rounded cursor-pointer hover:opacity-80"
style="background-color: var(--bg-secondary); color: var(--text-primary)"
data-action="browse-navigate"
data-path="${escapeHtmlLocal(fullPath)}">
📁 ${escapeHtmlLocal(dir)}
</div>
`;
});
}
html += `
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" data-action="browse-cancel" class="btn-secondary px-4 py-2 rounded">Cancel</button>
<button type="button" data-action="browse-select" data-path="${escapeHtmlLocal(data.current_path)}" class="btn-primary px-4 py-2 rounded">Select This Folder</button>
</div>
`;
container.innerHTML = html;
}
// Navigate to subdirectory
function navigateFolderBrowser(path: string): void {
void loadBrowseDirectories(path);
}
// Select folder and close browser
function selectBrowseFolder(path: string): void {
const input = document.getElementById(currentBrowseInputId) as HTMLInputElement;
if (input) {
input.value = path;
}
hideFolderBrowser();
}
// Hide folder browser modal
function hideFolderBrowser(): void {
const modal = document.getElementById('folder-browser-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
}
**NOTE:** Export the new folder browser functions by adding them to the existing window exports block at the end of library.ts (around line 380-387), not as a separate block:
```typescript
// Add these to the existing exports at lines 380-387:
(window as any).showFolderBrowser = showFolderBrowser;
(window as any).navigateFolderBrowser = navigateFolderBrowser;
(window as any).selectBrowseFolder = selectBrowseFolder;
(window as any).hideFolderBrowser = hideFolderBrowser;
```
---
#### 2.9 Frontend - Update Event Delegation Handler
**File:** `web/src/library.ts` (modify `handleLibraryListClick` function, around line 309)
**Add cases for folder browser actions:**
```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 += '<div class="mt-2 flex space-x-2">' +
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';
```
**Replace With:**
```typescript
container.innerHTML += '<div class="mt-2 flex space-x-2">' +
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
`<button data-action="browse-folder" data-input-id="folder-path-${libraryId}" ` +
'class="btn-secondary px-2 py-1 text-xs rounded">Browse</button>' +
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';
```
---
#### 2.11 Frontend - Folder Browser Modal
**File:** `templates/admin_library.templ`
**Add after Create Library Modal (after line 147):**
```html
<!-- Folder Browser Modal -->
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
<button type="button" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
</div>
<div id="folder-browser-content">
<!-- Directory listings will be rendered here -->
</div>
</div>
</div>
```
---
### Issue 3: Edit Library Button
#### 3.1 Implement `editLibrary()` Function
**File:** `web/src/library.ts` (lines 278-284)
**Current (placeholder):**
```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<void> {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const libraryData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
type: formData.get('type') as string
};
try {
const response = await (window as any).api.post('/libraries', libraryData);
await (window as any).api.handleResponse(response) as { data: Library };
if ((window as any).showToast?.success) {
(window as any).showToast.success('Library created successfully');
}
hideCreateLibraryModal();
form.reset();
void reloadLibraries();
} catch (error) {
(window as any).api.handleError(error, 'Failed to create library');
}
}
```
**Replace With:**
```typescript
async function handleCreateLibrarySubmit(event: Event): Promise<void> {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const libraryId = (document.getElementById('library-id') as HTMLInputElement)?.value;
const isEdit = !!libraryId;
const libraryData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
type: formData.get('type') as string
};
try {
const url = isEdit ? `/libraries/${libraryId}` : '/libraries';
const method = isEdit ? 'put' : 'post';
const response = await (window as any).api[method](url, libraryData);
if (isEdit) {
await (window as any).api.handleVoidResponse(response);
} else {
await (window as any).api.handleResponse(response) as { data: Library };
}
if ((window as any).showToast?.success) {
(window as any).showToast.success(isEdit ? 'Library updated successfully' : 'Library created successfully');
}
hideCreateLibraryModal();
form.reset();
// Clear library ID
const libraryIdInput = document.getElementById('library-id') as HTMLInputElement;
if (libraryIdInput) {
libraryIdInput.value = '';
}
void reloadLibraries();
} catch (error) {
(window as any).api.handleError(error, isEdit ? 'Failed to update library' : 'Failed to create library');
}
}
```
---
#### 3.3 Update Modal Title Reset
**File:** `web/src/library.ts` (lines 287-292)
**Current:**
```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
<form id="create-library-form">
<input type="hidden" id="library-id" name="id">
<!-- rest of form unchanged -->
</form>
```
---
### Issue 4: Delete Library Clarification
#### 4.1 Update Confirmation Message
**File:** `web/src/library.ts` (line 177 in `deleteLibrary()`)
**Current:**
```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