- 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
1230 lines
38 KiB
Markdown
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
|