add library management
This commit is contained in:
49
README.md
49
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/<id>`) |
|
||||
| `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=<id>` | GET | Scans the games library and returns structured game entries |
|
||||
| `/api/browse?libraryId=<id>&path=<subpath>` | GET | Lists the contents of a directory within a mixed library |
|
||||
| `/api/file?libraryId=<id>&path=<relpath>` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
{
|
||||
"id": "various",
|
||||
"name": "Various Media",
|
||||
"name": "Various",
|
||||
"path": "./data/Various Media",
|
||||
"type": "mixed"
|
||||
}
|
||||
|
||||
17
src/app/api/libraries/[id]/route.ts
Normal file
17
src/app/api/libraries/[id]/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<html lang="en">
|
||||
<body className="min-h-screen">
|
||||
<header className="border-b sticky top-0 z-40" style={{ borderColor: 'var(--border)', backgroundColor: 'var(--background)' }}>
|
||||
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center gap-3">
|
||||
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center justify-between gap-3">
|
||||
<a href="/" className="flex items-center gap-2 font-semibold text-lg tracking-tight" style={{ color: 'var(--text-primary)' }}>
|
||||
<span style={{ color: 'var(--accent)' }}>◈</span>
|
||||
MediaLore
|
||||
</a>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink href="/manage">Manage Libraries</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-7xl mx-auto px-6 py-8">
|
||||
|
||||
328
src/app/manage/page.tsx
Normal file
328
src/app/manage/page.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import type { Library, LibraryType } from '@/types'
|
||||
|
||||
const TYPE_LABELS: Record<LibraryType, string> = {
|
||||
games: 'Games',
|
||||
mixed: 'Mixed Media',
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ManagePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
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 (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
Manage Libraries
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Add or remove media library folders.
|
||||
</p>
|
||||
|
||||
<Section title="Configured Libraries">
|
||||
{loading ? (
|
||||
<LoadingRows />
|
||||
) : libraries.length === 0 ? (
|
||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
No libraries configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{libraries.map((lib) => (
|
||||
<LibraryRow key={lib.id} library={lib} onRemoved={refresh} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Add a Library">
|
||||
<AddLibraryForm onAdded={refresh} />
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section wrapper ──────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-xl border"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||
>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Library Row ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () => void }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="flex items-center gap-4 py-3 first:pt-0 last:pb-0">
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||
{library.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-xs font-mono truncate"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
title={library.path}
|
||||
>
|
||||
{library.path}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{confirming && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRemoveClick}
|
||||
disabled={removing}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
|
||||
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!confirming) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
||||
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!confirming) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Add Library Form ─────────────────────────────────────────────────────────
|
||||
|
||||
function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const [libPath, setLibPath] = useState('')
|
||||
const [type, setType] = useState<LibraryType>('games')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Name">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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)')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as LibraryType)}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 cursor-pointer"
|
||||
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)')}
|
||||
>
|
||||
<option value="games">Games</option>
|
||||
<option value="mixed">Mixed Media</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Path">
|
||||
<input
|
||||
type="text"
|
||||
value={libPath}
|
||||
onChange={(e) => 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)')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm rounded-lg px-3 py-2" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!submitting) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Adding…' : 'Add Library'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Field wrapper ────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Loading skeleton ─────────────────────────────────────────────────────────
|
||||
|
||||
function LoadingRows() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[70, 50, 85].map((w) => (
|
||||
<div key={w} className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-4 rounded animate-pulse"
|
||||
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
Libraries
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured
|
||||
</p>
|
||||
{libraries.length === 0 ? (
|
||||
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
<p className="text-lg mb-2">No libraries configured</p>
|
||||
<p className="text-sm">Add entries to <code className="font-mono text-xs px-1 py-0.5 rounded" style={{ background: 'var(--surface)' }}>libraries.json</code> to get started.</p>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
Libraries
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{libraries.map((lib) => (
|
||||
<LibraryCard key={lib.id} library={lib} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href="/manage"
|
||||
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{libraries.map((lib) => (
|
||||
<LibraryCard key={lib.id} library={lib} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
29
src/components/NavLink.tsx
Normal file
29
src/components/NavLink.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function NavLink({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
;(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}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user