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

38 KiB

Admin Library Page Improvements - Implementation Plan

Overview

Four improvements to the /admin/library page to fix bugs and add missing functionality.

Issues Addressed

Issue 1: Folder List Display Bugs

Current State: Folders can be added but disappear after adding; delete button doesn't work.

Root Causes:

  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:

async function apiDelete(url: string): Promise<Response> {
    return fetch(`/api${url}`, {
        method: 'DELETE',
        headers: {
            'Authorization': getAuthHeader()
        }
    });
}

Replace With:

async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> {
    return fetch(`/api${url}`, {
        method: 'DELETE',
        headers: {
            'Authorization': getAuthHeader(),
            'Content-Type': 'application/json'
        },
        body: data ? JSON.stringify(data) : undefined
    });
}

Rationale: Generic type parameter provides type safety - TypeScript will enforce that request bodies match the expected object structure (e.g., { folder_path: string }), preventing runtime errors from malformed requests. 100% backward compatible (optional parameter), only 2 callers exist in codebase.

Type Safety Note: The generic <T extends object> constraint ensures that:

  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:
    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:

if (container.classList.contains('hidden')) {
    // load and show folders
} else {
    container.classList.add('hidden');  // <-- HIDES on second click!
}

Fix: Remove toggle, always reload and show:

async function showLibraryFolders(libraryId: string): Promise<void> {
    const container = document.getElementById(`library-folders-${libraryId}`);
    if (!container) return;

    // Always reload content
    try {
        const response = await (window as any).api.get(`/libraries/${libraryId}/folders`);
        const folders = await (window as any).api.handleResponse(response) as LibraryFolder[];

        container.innerHTML = folders.map((folder: LibraryFolder) =>
            '<div class="flex justify-between items-center p-2 rounded" style="background-color: var(--bg-secondary); border-color: var(--border)">' +
                `<span class="text-sm" style="color: var(--text-primary)">${escapeHtmlLocal(folder.folder_path)}</span>` +
                `<button data-library-id="${libraryId}" data-folder-path="${escapeHtmlLocal(folder.folder_path)}" data-action="remove-folder" ` +
                    'class="text-xs text-red-500">Remove</button>' +
            '</div>'
        ).join('');

        // Add folder input with browse button (Issue 2 will add Browse button)
        container.innerHTML += '<div class="mt-2 flex space-x-2">' +
            `<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
                'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
            `<button data-library-id="${libraryId}" data-action="add-folder" ` +
                'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
        '</div>';

        container.classList.remove('hidden');
    } catch (error) {
        (window as any).api.handleError(error, 'Failed to load folders');
    }
}

1.3 Fix DELETE Request Body in removeLibraryFolder()

File: web/src/library.ts (modify removeLibraryFolder function, around line 259)

Add interface at top of file (with other interfaces around line 14):

interface DeleteFolderRequest {
    folder_path: string;
}

Current (broken):

async function removeLibraryFolder(libraryId: string, folderPath: string): Promise<void> {
    if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
        return;
    }

    try {
        const response = await (window as any).api.delete(`/libraries/${libraryId}/folders`);
        await (window as any).api.handleVoidResponse(response);

        if ((window as any).showToast?.success) {
            (window as any).showToast.success('Folder removed successfully');
        }

        void showLibraryFolders(libraryId); // Refresh
    } catch (error) {
        (window as any).api.handleError(error, 'Failed to remove folder');
    }
}

Replace With:

async function removeLibraryFolder(libraryId: string, folderPath: string): Promise<void> {
    if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
        return;
    }

    try {
        const response = await (window as any).api.delete<DeleteFolderRequest>(
            `/libraries/${libraryId}/folders`,
            { folder_path: folderPath }
        );
        await (window as any).api.handleVoidResponse(response);

        if ((window as any).showToast?.success) {
            (window as any).showToast.success('Folder removed successfully');
        }

        void showLibraryFolders(libraryId); // Refresh
    } catch (error) {
        (window as any).api.handleError(error, 'Failed to remove folder');
    }
}

Rationale: Backend DeleteLibraryFolder handler (library.go:237) reuses AddLibraryFolderRequest struct and requires { "folder_path": "..." } in request body. The c.Bind() call fails with "invalid request" error when body is missing, causing folder removal to silently fail. Using explicit DeleteFolderRequest interface provides type safety and prevents passing incorrect request shapes.


Issue 2: Folder Browser (Full-Stack)

2.1 Backend - Service Layer

File: internal/services/library_service.go

Add Import:

import (
    "os"  // Add this import for os.Stat, os.IsNotExist, os.ReadDir
)

Add Function:

// BrowseDirectories lists directories at a given path for folder browser UI
// Returns: (directories, currentPath, parentPath, error)
func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]string, string, string, error) {
    // Security: path traversal protection
    if strings.Contains(path, "..") {
        return nil, "", "", fmt.Errorf("path traversal not allowed")
    }

    cleanPath := filepath.Clean(path)

    // Check if path exists and is accessible
    fileInfo, err := os.Stat(cleanPath)
    if err != nil {
        if os.IsNotExist(err) {
            return nil, "", "", fmt.Errorf("path does not exist")
        }
        return nil, "", "", fmt.Errorf("path not accessible: %w", err)
    }

    if !fileInfo.IsDir() {
        return nil, "", "", fmt.Errorf("not a directory")
    }

    // Read directory contents
    entries, err := os.ReadDir(cleanPath)
    if err != nil {
        return nil, "", "", fmt.Errorf("failed to read directory: %w", err)
    }

    // Filter only directories
    var dirs []string
    for _, entry := range entries {
        if entry.IsDir() {
            dirs = append(dirs, entry.Name())
        }
    }

    // Get parent path for navigation
    parentPath := filepath.Dir(cleanPath)
    if parentPath == cleanPath {
        parentPath = "" // At root
    }

    return dirs, cleanPath, parentPath, nil
}

2.2 Backend - Handler

File: internal/handlers/library.go

Add Handler Function:

// BrowseDirectories returns directory listings for folder browser UI
func (h *LibraryHandler) BrowseDirectories(c echo.Context) error {
    path := c.QueryParam("path")
    if path == "" {
        path = "/" // Start from root
    }

    dirs, currentPath, parentPath, err := h.libraryService.BrowseDirectories(c.Request().Context(), path)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
    }

    return c.JSON(http.StatusOK, map[string]interface{}{
        "current_path": currentPath,
        "parent_path":  parentPath,
        "directories":  dirs,
    })
}

2.3 Backend - Router

File: internal/router/library.go

Add Route Registration (before line 31, before all /:id routes):

adminLibrary.GET("/browse", cfg.LibraryHandler.BrowseDirectories)

CRITICAL: Must come before /:id routes (line 31) to avoid route matching conflicts where /libraries/browse could be captured by /:id.


2.4 Backend - Unit Tests

File: internal/services/library_service_test.go (create if doesn't exist)

package services

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestBrowseDirectories(t *testing.T) {
    // BrowseDirectories operates on filesystem only - no database needed
    service := &LibraryService{}

    t.Run("blocks path traversal with ..", func(t *testing.T) {
        _, _, _, err := service.BrowseDirectories(context.Background(), "/etc/../root")
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "path traversal not allowed")
    })

    t.Run("returns error for non-existent path", func(t *testing.T) {
        _, _, _, err := service.BrowseDirectories(context.Background(), "/nonexistent/path")
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "path does not exist")
    })

    t.Run("returns error when path is a file", func(t *testing.T) {
        _, _, _, err := service.BrowseDirectories(context.Background(), "/etc/passwd")
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "not a directory")
    })

    t.Run("returns subdirectories for valid path", func(t *testing.T) {
        dirs, currentPath, parentPath, err := service.BrowseDirectories(context.Background(), "/tmp")
        assert.NoError(t, err)
        assert.NotEmpty(t, dirs)
        assert.Equal(t, "/tmp", currentPath)
        assert.Equal(t, "/", parentPath)
    })
}

2.5 Backend - Integration Tests

File: cmd/server/tests/library_browse_test.go (create new file)

package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
    setup := setupTestServer(t)

    t.Run("GET /api/libraries/browse - no authentication returns 401", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
        rec := httptest.NewRecorder()
        setup.Server.Config.Handler.ServeHTTP(rec, req)
        assert.Equal(t, http.StatusUnauthorized, rec.Code)
    })

    t.Run("GET /api/libraries/browse - regular user returns 403 forbidden", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
        req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
        rec := httptest.NewRecorder()
        setup.Server.Config.Handler.ServeHTTP(rec, req)
        assert.Equal(t, http.StatusForbidden, rec.Code)
    })

    t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
        req.Header.Set("Authorization", "Bearer "+setup.Token)
        rec := httptest.NewRecorder()
        setup.Server.Config.Handler.ServeHTTP(rec, req)
        assert.Equal(t, http.StatusOK, rec.Code)

        var data struct {
            CurrentPath string   `json:"current_path"`
            ParentPath  string   `json:"parent_path"`
            Directories []string `json:"directories"`
        }
        err := json.Unmarshal(rec.Body.Bytes(), &data)
        assert.NoError(t, err)
        assert.Equal(t, "/tmp", data.CurrentPath)
        assert.NotEmpty(t, data.Directories)
    })

    t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/api/libraries/browse?path=/etc/../root", nil)
        req.Header.Set("Authorization", "Bearer "+setup.Token)
        rec := httptest.NewRecorder()
        setup.Server.Config.Handler.ServeHTTP(rec, req)
        assert.Equal(t, http.StatusBadRequest, rec.Code)

        var errResp struct {
            Error string `json:"error"`
        }
        json.Unmarshal(rec.Body.Bytes(), &errResp)
        assert.Contains(t, errResp.Error, "path traversal not allowed")
    })
}

2.6 Bruno API Test (Manual Testing)

File: bruno/library/browse-folders.yml (create new file)

Purpose: Single manual test case for API contract verification and examples. Unit and integration tests cover all scenarios (no auth, user, admin, edge cases). Bruno provides a quick manual test for API contract validation.

meta:
  name: Browse library folders
  type: http
  seq: 1
config:
  test:
    filter: "response.status == 200"
    assertions:
      - type: stddev
        target: response_time
        threshold: 1000
      - type: jsonpath
        expression: response.directories
        condition: exists
      - type: jsonpath
        expression: response.current_path
        condition: exists
      - type: jsonpath
        expression: response.parent_path
        condition: exists
req:
  method: GET
  url: "{{base_url}}/api/libraries/browse?path=/tmp"
  headers:
    Authorization: "Bearer {{ADMIN_TOKEN}}"

2.7 User Documentation

File: docs/user/admin-library-management.md (create new file)

# Admin Library Management

## Adding Library Folders

When creating or managing a library, you can add folders containing your media files (ebooks, comics, manga).

### Using the Folder Browser

The admin library page includes a folder browser to help you select folders on the server:

1. Navigate to **Admin → Library Management**
2. Find the library you want to manage
3. Click the **Folders** button
4. Click **Browse** next to "Add folder path"
5. Navigate through the server's filesystem
6. Select a folder by clicking **Select This Folder**

### Security

- The folder browser only shows directories (not files)
- Path traversal is protected (cannot access parent directories with `..`)
- Only admin users can browse the filesystem

### Manual Entry

Alternatively, you can manually type the full server path if you know it:

/home/user/books /media/external/ebooks /var/lib/manga


2.8 Frontend - Folder Browser Functions

File: web/src/library.ts (add before initializeLibraryAdmin())

// Folder browser state
let currentBrowsePath = '';
let currentBrowseInputId = '';

// Show folder browser modal
function showFolderBrowser(inputId: string): void {
    currentBrowseInputId = inputId;
    currentBrowsePath = '/'; // Start at root

    const modal = document.getElementById('folder-browser-modal') as HTMLElement;
    if (modal) {
        modal.classList.remove('hidden');
        void loadBrowseDirectories(currentBrowsePath);
    }
}

// Load directories for browsing
async function loadBrowseDirectories(path: string): Promise<void> {
    try {
        const response = await (window as any).api.get(`/libraries/browse?path=${encodeURIComponent(path)}`);
        const data = await (window as any).api.handleResponse(response) as {
            current_path: string;
            parent_path: string;
            directories: string[];
        };

        currentBrowsePath = data.current_path;
        renderBrowseDirectories(data);
    } catch (error) {
        (window as any).api.handleError(error, 'Failed to load directories');
    }
}

// Render browse directories (uses event delegation via data-action attributes)
function renderBrowseDirectories(data: { current_path: string; parent_path: string; directories: string[] }): void {
    const container = document.getElementById('folder-browser-content');
    if (!container) return;

    let html = `
        <div class="flex items-center gap-2 mb-4">
            ${data.parent_path ?
                `<button type="button" data-action="browse-parent" data-path="${escapeHtmlLocal(data.parent_path)}" class="btn-secondary px-3 py-1 rounded">↑ Parent</button>`
                : ''}
            <span class="text-sm" style="color: var(--text-secondary)">${escapeHtmlLocal(data.current_path)}</span>
        </div>
        <div class="max-h-64 overflow-y-auto space-y-1">
    `;

    if (data.directories.length === 0) {
        html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
    } else {
        data.directories.forEach(dir => {
            const fullPath = data.current_path === '/' ? `/${dir}` : `${data.current_path}/${dir}`;
            html += `
                <div class="p-2 rounded cursor-pointer hover:opacity-80"
                     style="background-color: var(--bg-secondary); color: var(--text-primary)"
                     data-action="browse-navigate"
                     data-path="${escapeHtmlLocal(fullPath)}">
                    📁 ${escapeHtmlLocal(dir)}
                </div>
            `;
        });
    }

    html += `
        </div>
        <div class="mt-4 flex justify-end gap-2">
            <button type="button" data-action="browse-cancel" class="btn-secondary px-4 py-2 rounded">Cancel</button>
            <button type="button" data-action="browse-select" data-path="${escapeHtmlLocal(data.current_path)}" class="btn-primary px-4 py-2 rounded">Select This Folder</button>
        </div>
    `;

    container.innerHTML = html;
}

// Navigate to subdirectory
function navigateFolderBrowser(path: string): void {
    void loadBrowseDirectories(path);
}

// Select folder and close browser
function selectBrowseFolder(path: string): void {
    const input = document.getElementById(currentBrowseInputId) as HTMLInputElement;
    if (input) {
        input.value = path;
    }
    hideFolderBrowser();
}

// Hide folder browser modal
function hideFolderBrowser(): void {
    const modal = document.getElementById('folder-browser-modal') as HTMLElement;
    if (modal) {
        modal.classList.add('hidden');
    }
}

**NOTE:** Export the new folder browser functions by adding them to the existing window exports block at the end of library.ts (around line 380-387), not as a separate block:

```typescript
// Add these to the existing exports at lines 380-387:
(window as any).showFolderBrowser = showFolderBrowser;
(window as any).navigateFolderBrowser = navigateFolderBrowser;
(window as any).selectBrowseFolder = selectBrowseFolder;
(window as any).hideFolderBrowser = hideFolderBrowser;

2.9 Frontend - Update Event Delegation Handler

File: web/src/library.ts (modify handleLibraryListClick function, around line 309)

Add cases for folder browser actions:

function handleLibraryListClick(event: Event): void {
    const target = event.target as HTMLElement;
    const button = target.closest('button') as HTMLElement;
    if (!button) return;

    const action = button.dataset.action;
    const libraryId = button.dataset.libraryId;

    switch (action) {
        case 'show-folders':
            if (libraryId) showLibraryFolders(libraryId);
            break;
        case 'delete':
            if (libraryId) deleteLibrary(libraryId);
            break;
        case 'edit':
            if (libraryId) editLibrary(libraryId);
            break;
        case 'add-folder':
            if (libraryId) addLibraryFolder(libraryId);
            break;
        case 'remove-folder':
            if (libraryId && button.dataset.folderPath) {
                removeLibraryFolder(libraryId, button.dataset.folderPath);
            }
            break;
        case 'browse-folder':
            if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId);
            break;
    }
}

function handleFolderBrowserClick(event: Event): void {
    const target = event.target as HTMLElement;
    const button = target.closest('button') as HTMLElement;
    const div = target.closest('div[data-action]') as HTMLElement;

    // Handle button clicks
    if (button) {
        const action = button.dataset.action;
        const path = button.dataset.path;

        switch (action) {
            case 'browse-parent':
                if (path) navigateFolderBrowser(path);
                break;
            case 'browse-cancel':
                hideFolderBrowser();
                break;
            case 'browse-select':
                if (path) selectBrowseFolder(path);
                break;
        }
    }

    // Handle directory div clicks
    if (div && div.dataset.action === 'browse-navigate') {
        const path = div.dataset.path;
        if (path) navigateFolderBrowser(path);
    }
}

Update initializeLibraryAdmin to add folder browser event listener:

function initializeLibraryAdmin(): void {
    // Setup event listeners
    const librariesList = document.getElementById('libraries-list');
    if (librariesList) {
        librariesList.addEventListener('click', handleLibraryListClick);
    }

    const folderBrowserModal = document.getElementById('folder-browser-modal');
    if (folderBrowserModal) {
        folderBrowserModal.addEventListener('click', handleFolderBrowserClick);
    }

    document.addEventListener('click', handleGlobalClick);

    // ... rest of existing code
}

2.10 Frontend - Add Browse Button

File: web/src/library.ts (modify showLibraryFolders function, around line 214)

Current folder input:

container.innerHTML += '<div class="mt-2 flex space-x-2">' +
    `<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
        'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
    `<button data-library-id="${libraryId}" data-action="add-folder" ` +
        'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';

Replace With:

container.innerHTML += '<div class="mt-2 flex space-x-2">' +
    `<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
        'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
    `<button data-action="browse-folder" data-input-id="folder-path-${libraryId}" ` +
        'class="btn-secondary px-2 py-1 text-xs rounded">Browse</button>' +
    `<button data-library-id="${libraryId}" data-action="add-folder" ` +
        'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
'</div>';

2.11 Frontend - Folder Browser Modal

File: templates/admin_library.templ

Add after Create Library Modal (after line 147):

<!-- Folder Browser Modal -->
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
    <div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
        <div class="flex justify-between items-center mb-4">
            <h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
            <button type="button" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
        </div>
        <div id="folder-browser-content">
            <!-- Directory listings will be rendered here -->
        </div>
    </div>
</div>

Issue 3: Edit Library Button

3.1 Implement editLibrary() Function

File: web/src/library.ts (lines 278-284)

Current (placeholder):

function editLibrary(libraryId: string): void {
    console.log('Edit library:', libraryId);
    if ((window as any).showToast?.info) {
        (window as any).showToast.info('Edit library functionality coming soon');
    }
}

Replace With:

function editLibrary(libraryId: string): void {
    const library = libraries.find(l => l.id === libraryId);
    if (!library) {
        if ((window as any).showToast?.error) {
            (window as any).showToast.error('Library not found');
        }
        return;
    }

    // Populate form with existing data
    const form = document.getElementById('create-library-form') as HTMLFormElement;
    if (form) {
        const nameInput = form.querySelector('[name="name"]') as HTMLInputElement;
        const descInput = form.querySelector('[name="description"]') as HTMLTextAreaElement;
        const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement;

        if (nameInput) nameInput.value = library.name;
        if (descInput) descInput.value = library.description || '';
        if (typeInput) typeInput.value = library.library_type_id;
    }

    // Update modal title
    const modalTitle = document.querySelector('#create-library-modal h2');
    if (modalTitle) {
        modalTitle.textContent = 'Edit Library';
    }

    // Store library ID for form submission
    const libraryIdInput = document.getElementById('library-id') as HTMLInputElement;
    if (libraryIdInput) {
        libraryIdInput.value = libraryId;
    }

    showCreateLibraryModal();
}

3.2 Modify Form Handler

File: web/src/library.ts (lines 144-170)

Current:

async function handleCreateLibrarySubmit(event: Event): Promise<void> {
    event.preventDefault();

    const form = event.target as HTMLFormElement;
    const formData = new FormData(form);

    const libraryData = {
        name: formData.get('name') as string,
        description: formData.get('description') as string,
        type: formData.get('type') as string
    };

    try {
        const response = await (window as any).api.post('/libraries', libraryData);
        await (window as any).api.handleResponse(response) as { data: Library };

        if ((window as any).showToast?.success) {
            (window as any).showToast.success('Library created successfully');
        }

        hideCreateLibraryModal();
        form.reset();
        void reloadLibraries();
    } catch (error) {
        (window as any).api.handleError(error, 'Failed to create library');
    }
}

Replace With:

async function handleCreateLibrarySubmit(event: Event): Promise<void> {
    event.preventDefault();

    const form = event.target as HTMLFormElement;
    const formData = new FormData(form);

    const libraryId = (document.getElementById('library-id') as HTMLInputElement)?.value;
    const isEdit = !!libraryId;

    const libraryData = {
        name: formData.get('name') as string,
        description: formData.get('description') as string,
        type: formData.get('type') as string
    };

    try {
        const url = isEdit ? `/libraries/${libraryId}` : '/libraries';
        const method = isEdit ? 'put' : 'post';

        const response = await (window as any).api[method](url, libraryData);

        if (isEdit) {
            await (window as any).api.handleVoidResponse(response);
        } else {
            await (window as any).api.handleResponse(response) as { data: Library };
        }

        if ((window as any).showToast?.success) {
            (window as any).showToast.success(isEdit ? 'Library updated successfully' : 'Library created successfully');
        }

        hideCreateLibraryModal();
        form.reset();

        // Clear library ID
        const libraryIdInput = document.getElementById('library-id') as HTMLInputElement;
        if (libraryIdInput) {
            libraryIdInput.value = '';
        }

        void reloadLibraries();
    } catch (error) {
        (window as any).api.handleError(error, isEdit ? 'Failed to update library' : 'Failed to create library');
    }
}

3.3 Update Modal Title Reset

File: web/src/library.ts (lines 287-292)

Current:

function showCreateLibraryModal(): void {
    const modal = document.getElementById('create-library-modal') as HTMLElement;
    if (modal) {
        modal.classList.remove('hidden');
    }
}

Replace With:

function showCreateLibraryModal(): void {
    const modal = document.getElementById('create-library-modal') as HTMLElement;
    if (modal) {
        modal.classList.remove('hidden');

        // Reset modal title to "Create Library"
        const modalTitle = document.querySelector('#create-library-modal h2');
        if (modalTitle) {
            modalTitle.textContent = 'Create Library';
        }
    }
}

3.4 Add Hidden Input to Template

File: templates/admin_library.templ (line 124, in form)

Add at beginning of form:

<form id="create-library-form">
    <input type="hidden" id="library-id" name="id">
    <!-- rest of form unchanged -->
</form>

Issue 4: Delete Library Clarification

4.1 Update Confirmation Message

File: web/src/library.ts (line 177 in deleteLibrary())

Current:

if (!confirm(`Delete library "${library.name}"? This will permanently remove all associated media and cannot be undone.`)) {
    return;
}

Replace With:

const message = `Are you sure you want to delete "${library.name}"?

This will remove:
• Library metadata from the database
• All folder references
• All book records from the database

⚠️ Book files on disk will NOT be deleted.

This action cannot be undone.`;

if (!confirm(message)) {
    return;
}

Git Commit Structure

Frontend Commits (Issues 3, 4, 1)

feat(frontend): implement library edit functionality

- Reuse Create Library modal for edit mode
- Add hidden library-id input to track create vs edit
- Update handleCreateLibrarySubmit to detect mode and use PUT vs POST
- Implement editLibrary() to populate modal with existing data
- Pass library data to Edit button via data attributes
- Reset modal title when opening for create mode

Fixes: Issue 3

---

fix(frontend): clarify library delete confirmation message

- Explicitly state book files on disk are NOT deleted
- List what gets removed (database records only)
- Improve user understanding of delete operation
- Use multi-line format for better readability

Fixes: Issue 4

---

fix(frontend): resolve folder list display bugs

- Fix toggle logic that hid folders on second click
- Remove toggle behavior, always reload and show folders
- Add data parameter support to apiDelete() in api.ts
- Fix DELETE /api/libraries/:id/folders to include request body
- 100% backward compatible (optional parameter)

Fixes: Issue 1

Full-Stack Commits (Issue 2)

feat(backend): add server-side directory browsing API

- Add BrowseDirectories() to library service with path traversal protection
- Add BrowseDirectories handler with proper error handling
- Register GET /api/libraries/browse endpoint (admin-only)
- Returns current path, parent path, and list of subdirectories
- Security: blocks "..", validates path exists, checks is directory

Fixes: Issue 2 (backend)

---

test(backend): add unit and integration tests for folder browsing

- Add unit tests in internal/services/library_service_test.go
  - Test path traversal protection
  - Test non-existent path handling
  - Test file vs directory validation
  - Test successful directory listing
- Add integration tests in cmd/server/tests/library_browse_test.go
  - Use setupTestServer() helper from test_helpers.go
  - Test no authentication returns 401
  - Test regular user returns 403 forbidden
  - Test admin can browse directories
  - Test path traversal blocking
- All tests use table-driven approach with t.Run()

Fixes: Issue 2 (tests)

---

docs(api): document library folder browsing endpoint

- Add docs/developer/api/libraries/browse-folders.md
- Document security features (path traversal protection, admin-only)
- Include usage examples and error responses
- Clarify browses server filesystem, not client's

docs(user): add admin library folder browser documentation

- Add docs/user/admin-library-management.md
- Document how to use the folder browser UI
- Include security notes and manual entry instructions
- Explain server filesystem browsing behavior

Fixes: Issue 2 (documentation)

---

test(bruno): add manual API contract test for folder browsing

- Create bruno/library/browse-folders.yml
- Single manual test case for admin context
- Unit/integration tests cover all scenarios (no auth, user, admin)
- Bruno provides quick manual API contract verification

Fixes: Issue 2 (manual testing)

---

feat(frontend): add folder browser UI for library management

- Add folder browser modal to admin/library page
- Implement directory navigation in library.ts
- Add Browse button next to folder path input
- Connect to backend /api/libraries/browse endpoint
- Support parent directory navigation and path selection

Fixes: Issue 2 (frontend)

Verification Steps

After each commit:

# 1. Verify Go compilation
go build ./...

# 2. Run tests
go test ./... -v

# 3. Run verification script
bash scripts/verify-guidelines.sh

# 4. Review git diff
git diff

# 5. Rebuild container for testing
podman compose up --build -d

Testing Checklist

Issue 1: Folder List Bugs

  • Click "Folders" button - folder list appears
  • Click "Folders" again - list refreshes (doesn't hide)
  • Add new folder - folder appears in list
  • Click "Remove" on folder - confirmation dialog appears
  • Confirm removal - folder removed from list
  • Check browser console - no errors

Issue 2: Folder Browser

  • Click "Browse" button - folder browser modal opens
  • See list of directories starting at root (/)
  • Click on directory - navigate into it
  • Click "↑ Parent" - navigate to parent directory
  • Click "Select This Folder" - path entered in input field
  • Modal closes after selection
  • Test path traversal protection (try URL with "..") - should error
  • Test with non-existent path - should show error
  • Check browser console - no errors

Issue 3: Edit Library

  • Click "Edit" button on library - modal opens with library data
  • Modal title shows "Edit Library"
  • Form is pre-filled with existing library data
  • Modify name/description/type
  • Click "Create" button - library updates
  • Success toast appears
  • Library list refreshes with updated data
  • Open modal again - shows "Create Library" title
  • Create new library - still works

Issue 4: Delete Clarification

  • Click "Delete" button on library
  • See improved confirmation message
  • Message explicitly states book files NOT deleted
  • Message lists what WILL be deleted
  • Cancel - nothing happens
  • Confirm - library deleted, success toast shown

Files Summary

Frontend Only

  • web/src/api.ts - Add data parameter to 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