418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useRef } from 'react'
|
|
import Image from 'next/image'
|
|
import type { Library, LibraryType } from '@/types'
|
|
|
|
const TYPE_ICONS: Record<string, string> = {
|
|
games: '🎮',
|
|
mixed: '🗂️',
|
|
movies: '🎬',
|
|
tv: '📺',
|
|
}
|
|
|
|
const TYPE_LABELS: Record<LibraryType, string> = {
|
|
games: 'Games',
|
|
mixed: 'Mixed Media',
|
|
movies: 'Movies',
|
|
tv: 'TV Shows',
|
|
}
|
|
|
|
// ─── 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} onUpdated={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,
|
|
onUpdated,
|
|
}: {
|
|
library: Library
|
|
onRemoved: () => void
|
|
onUpdated: () => void
|
|
}) {
|
|
const [confirming, setConfirming] = useState(false)
|
|
const [removing, setRemoving] = useState(false)
|
|
const [uploadingCover, setUploadingCover] = useState(false)
|
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleRemoveClick = () => {
|
|
if (!confirming) {
|
|
setConfirming(true)
|
|
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
const handleCoverChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setUploadingCover(true)
|
|
const form = new FormData()
|
|
form.append('cover', file)
|
|
await fetch(`/api/library-cover/${encodeURIComponent(library.id)}`, {
|
|
method: 'POST',
|
|
body: form,
|
|
})
|
|
setUploadingCover(false)
|
|
// Reset input so the same file can be re-selected
|
|
e.target.value = ''
|
|
onUpdated()
|
|
}
|
|
|
|
const handleRemoveCover = async () => {
|
|
await fetch(`/api/library-cover/${encodeURIComponent(library.id)}`, { method: 'DELETE' })
|
|
onUpdated()
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-4 py-3 first:pt-0 last:pb-0">
|
|
{/* Cover thumbnail */}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploadingCover}
|
|
title="Click to change cover image"
|
|
className="flex-shrink-0 w-16 h-10 rounded-lg overflow-hidden relative transition-opacity disabled:opacity-50"
|
|
style={{ border: '1px solid var(--border)' }}
|
|
>
|
|
{library.coverExt ? (
|
|
<Image
|
|
src={`/api/library-cover/${library.id}`}
|
|
alt=""
|
|
fill
|
|
className="object-cover"
|
|
unoptimized
|
|
/>
|
|
) : (
|
|
<span
|
|
className="flex items-center justify-center w-full h-full text-xl"
|
|
style={{ backgroundColor: 'var(--border)' }}
|
|
>
|
|
{TYPE_ICONS[library.type] ?? '📁'}
|
|
</span>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
className="hidden"
|
|
onChange={handleCoverChange}
|
|
/>
|
|
</button>
|
|
|
|
{/* 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">
|
|
{library.coverExt && (
|
|
<button
|
|
onClick={handleRemoveCover}
|
|
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)')}
|
|
>
|
|
Remove cover
|
|
</button>
|
|
)}
|
|
{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 — fire scan for the new library (fire-and-forget)
|
|
void fetch(`/api/scan/${encodeURIComponent((data as { id: string }).id)}`, { method: 'POST' })
|
|
|
|
// 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>
|
|
<option value="movies">Movies</option>
|
|
<option value="tv">TV Shows</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>
|
|
)
|
|
}
|