From 90528c476893418d171defdb7653aa0563a6a7f3 Mon Sep 17 00:00:00 2001 From: Garret Patti Date: Wed, 25 Mar 2026 16:40:01 -0400 Subject: [PATCH] add library management --- README.md | 49 ++--- libraries.json | 2 +- src/app/api/libraries/[id]/route.ts | 17 ++ src/app/api/libraries/route.ts | 35 ++- src/app/layout.tsx | 6 +- src/app/manage/page.tsx | 328 ++++++++++++++++++++++++++++ src/app/page.tsx | 44 ++-- src/components/NavLink.tsx | 29 +++ src/lib/libraries.ts | 95 +++++++- 9 files changed, 552 insertions(+), 53 deletions(-) create mode 100644 src/app/api/libraries/[id]/route.ts create mode 100644 src/app/manage/page.tsx create mode 100644 src/components/NavLink.tsx diff --git a/README.md b/README.md index 4b67636..25b33a1 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,30 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem. - **Games library** — displays a grid of game cover art scanned from folders. Each game folder is expected to contain a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link for the zip. - **Mixed media library** — a folder-navigable browser that mirrors the directory structure on disk. Videos open in an inline player (with full seek support via HTTP range requests). Images open in a lightbox. Other files are opened in a new tab. -- **Configurable libraries** — libraries are registered in `libraries.json` at the project root; no database required. +- **Library management UI** — add and remove libraries at `/manage` without touching any config files. Configuration persists across restarts in `libraries.json`. - **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served. ## Project Structure ``` MediaLoreWeb/ -├── libraries.json # Library configuration +├── libraries.json # Runtime library config (managed via UI, do not edit by hand) ├── data/ # Example media (not committed to production) ├── src/ │ ├── app/ │ │ ├── layout.tsx -│ │ ├── page.tsx # Home — library cards +│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty) +│ │ ├── manage/page.tsx # Library management — add/remove libraries │ │ ├── library/[id]/page.tsx # Library view (games or mixed) │ │ └── api/ -│ │ ├── libraries/route.ts # GET /api/libraries +│ │ ├── libraries/route.ts # GET /api/libraries, POST /api/libraries +│ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id │ │ ├── games/route.ts # GET /api/games?libraryId= │ │ ├── browse/route.ts # GET /api/browse?libraryId=&path= │ │ └── file/route.ts # GET /api/file?libraryId=&path= │ ├── components/ │ │ ├── LibraryCard.tsx +│ │ ├── NavLink.tsx │ │ ├── games/ │ │ │ ├── GamesView.tsx │ │ │ └── GameDetailModal.tsx @@ -35,7 +38,7 @@ MediaLoreWeb/ │ │ ├── VideoPlayerModal.tsx │ │ └── ImageLightbox.tsx │ ├── lib/ -│ │ ├── libraries.ts # Config parsing and path resolution +│ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers │ │ ├── games.ts # Games library scanner │ │ └── files.ts # Mixed library directory scanner │ └── types/ @@ -66,33 +69,21 @@ npm run lint # Run ESLint ## Library Configuration -Libraries are defined in `libraries.json` at the project root: +Libraries are managed through the **Manage Libraries** screen at `/manage` in the app. No manual file editing is required. -```json -[ - { - "id": "games", - "name": "Games", - "path": "./data/Games", - "type": "games" - }, - { - "id": "various", - "name": "Various Media", - "path": "./data/Various Media", - "type": "mixed" - } -] -``` +When you add a library via the UI, you provide: | Field | Description | |--------|-------------| -| `id` | URL-safe unique identifier used in routes (`/library/`) | -| `name` | Display name shown in the UI | -| `path` | Absolute or project-relative path to the library root folder | -| `type` | `"games"` or `"mixed"` | +| Name | Display name shown in the UI | +| Path | Absolute or project-relative path to the library root folder on disk | +| Type | `Games` or `Mixed Media` | -Paths can point anywhere on the filesystem — they do not need to be inside the project directory. Restart the dev server after editing `libraries.json`. +The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root and persists across restarts. + +If no libraries are configured, navigating to `/` automatically redirects to `/manage`. + +Paths can point anywhere on the filesystem — they do not need to be inside the project directory. ## Library Folder Conventions @@ -124,7 +115,9 @@ All API routes are server-side. File paths are never exposed in client-side stat | Route | Method | Description | |-------|--------|-------------| -| `/api/libraries` | GET | Returns the full library list from `libraries.json` | +| `/api/libraries` | GET | Returns the full configured library list | +| `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }`. Validates the path exists. | +| `/api/libraries/:id` | DELETE | Removes a library by id | | `/api/games?libraryId=` | GET | Scans the games library and returns structured game entries | | `/api/browse?libraryId=&path=` | GET | Lists the contents of a directory within a mixed library | | `/api/file?libraryId=&path=` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback | diff --git a/libraries.json b/libraries.json index e4f904b..25200cc 100644 --- a/libraries.json +++ b/libraries.json @@ -7,7 +7,7 @@ }, { "id": "various", - "name": "Various Media", + "name": "Various", "path": "./data/Various Media", "type": "mixed" } diff --git a/src/app/api/libraries/[id]/route.ts b/src/app/api/libraries/[id]/route.ts new file mode 100644 index 0000000..feef0ce --- /dev/null +++ b/src/app/api/libraries/[id]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, removeLibrary } from '@/lib/libraries' + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + + const library = getLibrary(id) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + + removeLibrary(id) + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/libraries/route.ts b/src/app/api/libraries/route.ts index bda4493..8fd1c61 100644 --- a/src/app/api/libraries/route.ts +++ b/src/app/api/libraries/route.ts @@ -1,12 +1,41 @@ -import { NextResponse } from 'next/server' -import { getLibraries } from '@/lib/libraries' +import { NextRequest, NextResponse } from 'next/server' +import { getLibraries, addLibrary } from '@/lib/libraries' +import type { LibraryType } from '@/types' export function GET() { try { const libraries = getLibraries() return NextResponse.json(libraries) } catch (err) { - console.error('Failed to read libraries.json', err) + console.error('Failed to read libraries', err) return NextResponse.json({ error: 'Failed to load libraries' }, { status: 500 }) } } + +export async function POST(request: NextRequest) { + let body: { name?: string; path?: string; type?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { name, path, type } = body + + if (!name || !path || !type) { + return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 }) + } + + const validTypes: LibraryType[] = ['games', 'mixed'] + if (!validTypes.includes(type as LibraryType)) { + return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 }) + } + + try { + const library = addLibrary(name, path, type as LibraryType) + return NextResponse.json(library, { status: 201 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to add library' + return NextResponse.json({ error: message }, { status: 400 }) + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4ca06dd..04092fa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import NavLink from '@/components/NavLink' import './globals.css' export const metadata: Metadata = { @@ -15,11 +16,14 @@ export default function RootLayout({
-
+
MediaLore +
diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx new file mode 100644 index 0000000..7b922a0 --- /dev/null +++ b/src/app/manage/page.tsx @@ -0,0 +1,328 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import type { Library, LibraryType } from '@/types' + +const TYPE_LABELS: Record = { + games: 'Games', + mixed: 'Mixed Media', +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function ManagePage() { + const [libraries, setLibraries] = useState([]) + const [loading, setLoading] = useState(true) + + const refresh = () => { + fetch('/api/libraries') + .then((r) => r.json()) + .then((data: Library[]) => { + setLibraries(data) + setLoading(false) + }) + .catch(() => setLoading(false)) + } + + useEffect(() => { + refresh() + }, []) + + return ( +
+

+ Manage Libraries +

+

+ Add or remove media library folders. +

+ +
+ {loading ? ( + + ) : libraries.length === 0 ? ( +

+ No libraries configured yet. +

+ ) : ( +
+ {libraries.map((lib) => ( + + ))} +
+ )} +
+ +
+ +
+
+ ) +} + +// ─── Section wrapper ────────────────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
+
{children}
+
+
+ ) +} + +// ─── Library Row ────────────────────────────────────────────────────────────── + +function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () => void }) { + const [confirming, setConfirming] = useState(false) + const [removing, setRemoving] = useState(false) + const cancelRef = useRef | null>(null) + + const handleRemoveClick = () => { + if (!confirming) { + setConfirming(true) + // Auto-cancel confirmation after 4s if user does nothing + cancelRef.current = setTimeout(() => setConfirming(false), 4000) + return + } + // Second click — confirmed + if (cancelRef.current) clearTimeout(cancelRef.current) + setRemoving(true) + fetch(`/api/libraries/${encodeURIComponent(library.id)}`, { method: 'DELETE' }) + .then(() => onRemoved()) + .catch(() => setRemoving(false)) + } + + const handleCancel = () => { + if (cancelRef.current) clearTimeout(cancelRef.current) + setConfirming(false) + } + + return ( +
+ {/* Info */} +
+
+ + {library.name} + + + {TYPE_LABELS[library.type] ?? library.type} + +
+

+ {library.path} +

+
+ + {/* Actions */} +
+ {confirming && ( + + )} + +
+
+ ) +} + +// ─── Add Library Form ───────────────────────────────────────────────────────── + +function AddLibraryForm({ onAdded }: { onAdded: () => void }) { + const [name, setName] = useState('') + const [libPath, setLibPath] = useState('') + const [type, setType] = useState('games') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + + try { + const res = await fetch('/api/libraries', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, path: libPath, type }), + }) + + const data = await res.json() + + if (!res.ok) { + setError(data.error ?? 'Something went wrong.') + setSubmitting(false) + return + } + + // Success — reset form + setName('') + setLibPath('') + setType('games') + setSubmitting(false) + onAdded() + } catch { + setError('Network error. Please try again.') + setSubmitting(false) + } + } + + return ( +
+
+ + setName(e.target.value)} + placeholder="e.g. Games" + required + className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')} + onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')} + /> + + + + +
+ + + setLibPath(e.target.value)} + placeholder="e.g. /mnt/nas/Games or ./data/Games" + required + className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')} + onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')} + /> + + + {error && ( +

+ {error} +

+ )} + +
+ +
+
+ ) +} + +// ─── Field wrapper ──────────────────────────────────────────────────────────── + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +// ─── Loading skeleton ───────────────────────────────────────────────────────── + +function LoadingRows() { + return ( +
+ {[70, 50, 85].map((w) => ( +
+
+
+ ))} +
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 24d6831..8d673a0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,29 +1,39 @@ +import { redirect } from 'next/navigation' import { getLibraries } from '@/lib/libraries' import LibraryCard from '@/components/LibraryCard' +import Link from 'next/link' export default function HomePage() { const libraries = getLibraries() + if (libraries.length === 0) { + redirect('/manage') + } + return (
-

- Libraries -

-

- {libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured -

- {libraries.length === 0 ? ( -
-

No libraries configured

-

Add entries to libraries.json to get started.

+
+
+

+ Libraries +

+

+ {libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured +

- ) : ( -
- {libraries.map((lib) => ( - - ))} -
- )} + + Manage + +
+
+ {libraries.map((lib) => ( + + ))} +
) } diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx new file mode 100644 index 0000000..6d02213 --- /dev/null +++ b/src/components/NavLink.tsx @@ -0,0 +1,29 @@ +'use client' + +import Link from 'next/link' + +export default function NavLink({ + href, + children, +}: { + href: string + children: React.ReactNode +}) { + return ( + { + ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)' + ;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)' + }} + onMouseLeave={(e) => { + ;(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent' + ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' + }} + > + {children} + + ) +} diff --git a/src/lib/libraries.ts b/src/lib/libraries.ts index 62c1289..0f834ca 100644 --- a/src/lib/libraries.ts +++ b/src/lib/libraries.ts @@ -1,12 +1,17 @@ import path from 'path' import fs from 'fs' -import type { Library } from '@/types' +import type { Library, LibraryType } from '@/types' const CONFIG_PATH = path.resolve(process.cwd(), 'libraries.json') +const CONFIG_TMP = CONFIG_PATH + '.tmp' export function getLibraries(): Library[] { - const raw = fs.readFileSync(CONFIG_PATH, 'utf-8') - return JSON.parse(raw) as Library[] + try { + const raw = fs.readFileSync(CONFIG_PATH, 'utf-8') + return JSON.parse(raw) as Library[] + } catch { + return [] + } } export function getLibrary(id: string): Library | undefined { @@ -36,3 +41,87 @@ export function resolveAndJail(libraryRoot: string, subpath: string): string { } return resolved } + +/** + * Slugifies a name into a URL-safe id. + * e.g. "My Games Library" → "my-games-library" + */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +/** + * Atomically writes the library list to disk. + */ +function writeLibraries(libraries: Library[]): void { + const json = JSON.stringify(libraries, null, 2) + '\n' + fs.writeFileSync(CONFIG_TMP, json, 'utf-8') + fs.renameSync(CONFIG_TMP, CONFIG_PATH) +} + +/** + * Adds a new library. Validates the path exists as a directory. + * Throws a descriptive Error on failure. + */ +export function addLibrary(name: string, libraryPath: string, type: LibraryType): Library { + const trimmedName = name.trim() + const trimmedPath = libraryPath.trim() + + if (!trimmedName) throw new Error('Name is required.') + if (!trimmedPath) throw new Error('Path is required.') + + // Validate the path exists and is a directory + const resolved = path.isAbsolute(trimmedPath) + ? trimmedPath + : path.resolve(process.cwd(), trimmedPath) + + let stat: fs.Stats + try { + stat = fs.statSync(resolved) + } catch { + throw new Error(`Path does not exist: ${trimmedPath}`) + } + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${trimmedPath}`) + } + + const libraries = getLibraries() + + // Generate a unique id + const baseId = slugify(trimmedName) || 'library' + let id = baseId + let suffix = 2 + while (libraries.some((lib) => lib.id === id)) { + id = `${baseId}-${suffix++}` + } + + // Reject exact duplicate names (same name, case-insensitive) + const duplicate = libraries.find( + (lib) => lib.name.toLowerCase() === trimmedName.toLowerCase() + ) + if (duplicate) { + throw new Error(`A library named "${trimmedName}" already exists.`) + } + + const newLibrary: Library = { id, name: trimmedName, path: trimmedPath, type } + writeLibraries([...libraries, newLibrary]) + return newLibrary +} + +/** + * Removes a library by id. Returns true if removed, false if not found. + */ +export function removeLibrary(id: string): boolean { + const libraries = getLibraries() + const index = libraries.findIndex((lib) => lib.id === id) + if (index === -1) return false + const updated = libraries.filter((lib) => lib.id !== id) + writeLibraries(updated) + return true +}