Backend: - user roles (DB + JWT + first-user-is-admin) - volume-aware file resolver (multi-volume asset serving) - directory scanner uses volume URI directly - date-summary endpoint (capture date from EXIF) - timeline ordered by capture date - list endpoints: volumes, plugins, pipelines, library paths - delete endpoints: volumes, library paths - configurable upload body limit (MAX_UPLOAD_BYTES) Frontend: - auth: login/register, token refresh, role-based admin gate - timeline: date-grouped grid, infinite scroll, date scrubber - image viewer: fullscreen zoom/pan/pinch, metadata sidebar - upload: drag-drop, sequential upload, progress tracking - albums: create, add/remove photos, asset picker dialog - admin: storage (import library), jobs (pagination, error details), plugins (list + toggle), pipelines, sidecars, duplicates - multi-select mode with add-to-album action - TanStack Query for all data fetching
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import {
|
|
useVolumes,
|
|
useRegisterVolume,
|
|
useDeleteVolume,
|
|
useLibraryPaths,
|
|
useRegisterLibraryPath,
|
|
useDeleteLibraryPath,
|
|
} from "@/hooks/use-storage-admin"
|
|
import { useEnqueueJob } from "@/hooks/use-jobs"
|
|
import { useAuth } from "@/hooks/use-auth"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card"
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { toast } from "sonner"
|
|
import { FolderSyncIcon, Trash2Icon } from "lucide-react"
|
|
|
|
export default function StoragePage() {
|
|
const volumes = useVolumes()
|
|
const paths = useLibraryPaths()
|
|
const registerVolume = useRegisterVolume()
|
|
const deleteVolume = useDeleteVolume()
|
|
const registerPath = useRegisterLibraryPath()
|
|
const deletePath = useDeleteLibraryPath()
|
|
const enqueueJob = useEnqueueJob()
|
|
const { user } = useAuth()
|
|
|
|
const [volName, setVolName] = useState("")
|
|
const [volUri, setVolUri] = useState("")
|
|
const [volWritable, setVolWritable] = useState(true)
|
|
|
|
const [pathVolumeId, setPathVolumeId] = useState("")
|
|
const [pathRelative, setPathRelative] = useState("")
|
|
const [pathIngest, setPathIngest] = useState(true)
|
|
|
|
const [importUri, setImportUri] = useState("")
|
|
const [importName, setImportName] = useState("")
|
|
const [importing, setImporting] = useState(false)
|
|
|
|
const handleCreateVolume = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
try {
|
|
await registerVolume.mutateAsync({
|
|
volume_name: volName,
|
|
uri_prefix: volUri,
|
|
is_writable: volWritable,
|
|
})
|
|
setVolName("")
|
|
setVolUri("")
|
|
toast.success("Volume registered")
|
|
} catch {
|
|
toast.error("Failed to register volume")
|
|
}
|
|
}
|
|
|
|
const handleCreatePath = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!user) return
|
|
try {
|
|
await registerPath.mutateAsync({
|
|
volume_id: pathVolumeId,
|
|
relative_path: pathRelative,
|
|
owner_id: user.id,
|
|
is_ingest_destination: pathIngest,
|
|
})
|
|
setPathRelative("")
|
|
toast.success("Library path registered")
|
|
} catch {
|
|
toast.error("Failed to register library path")
|
|
}
|
|
}
|
|
|
|
const handleImportLibrary = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!user || !importUri) return
|
|
setImporting(true)
|
|
try {
|
|
const vol = await registerVolume.mutateAsync({
|
|
volume_name: importName || "imported",
|
|
uri_prefix: importUri,
|
|
is_writable: false,
|
|
})
|
|
const path = await registerPath.mutateAsync({
|
|
volume_id: vol.id,
|
|
relative_path: "",
|
|
owner_id: user.id,
|
|
is_ingest_destination: false,
|
|
})
|
|
await enqueueJob.mutateAsync({
|
|
job_type: "scan_directory",
|
|
payload: { library_path_id: path.id },
|
|
})
|
|
setImportUri("")
|
|
setImportName("")
|
|
toast.success("Import started — check Jobs page for progress")
|
|
} catch {
|
|
toast.error("Import failed")
|
|
} finally {
|
|
setImporting(false)
|
|
}
|
|
}
|
|
|
|
const volumeList = volumes.data ?? []
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<h1 className="text-lg font-semibold">Storage Management</h1>
|
|
|
|
{/* Import Library */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FolderSyncIcon className="h-4 w-4" />
|
|
Import Library
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Point to an existing photo directory — registers a volume, library
|
|
path, and starts scanning in one step
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form
|
|
onSubmit={handleImportLibrary}
|
|
className="flex items-end gap-2"
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-xs">Name</Label>
|
|
<Input
|
|
value={importName}
|
|
onChange={(e) => setImportName(e.target.value)}
|
|
placeholder="family-photos"
|
|
className="h-8 w-40"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
<Label className="text-xs">Directory Path</Label>
|
|
<Input
|
|
required
|
|
value={importUri}
|
|
onChange={(e) => setImportUri(e.target.value)}
|
|
placeholder="file:///mnt/nas/photos"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
<Button type="submit" size="sm" disabled={importing}>
|
|
{importing ? "Importing..." : "Import"}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Separator />
|
|
|
|
{/* Volumes */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Volumes</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
{volumes.isLoading ? (
|
|
<Spinner />
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>URI Prefix</TableHead>
|
|
<TableHead>Writable</TableHead>
|
|
<TableHead className="w-10" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{volumeList.map((v) => (
|
|
<TableRow key={v.id}>
|
|
<TableCell className="font-mono text-sm">
|
|
{v.volume_name}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">
|
|
{v.uri_prefix}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={v.is_writable ? "default" : "secondary"}>
|
|
{v.is_writable ? "Yes" : "No"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={async () => {
|
|
try {
|
|
await deleteVolume.mutateAsync(v.id)
|
|
toast.success("Volume deleted")
|
|
} catch {
|
|
toast.error("Failed to delete volume")
|
|
}
|
|
}}
|
|
>
|
|
<Trash2Icon className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
<form onSubmit={handleCreateVolume} className="flex items-end gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-xs">Name</Label>
|
|
<Input
|
|
required
|
|
value={volName}
|
|
onChange={(e) => setVolName(e.target.value)}
|
|
placeholder="local"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-xs">URI Prefix</Label>
|
|
<Input
|
|
required
|
|
value={volUri}
|
|
onChange={(e) => setVolUri(e.target.value)}
|
|
placeholder="file:///data/media"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 pb-1">
|
|
<Checkbox
|
|
id="vol-writable"
|
|
checked={volWritable}
|
|
onCheckedChange={(c) => setVolWritable(c === true)}
|
|
/>
|
|
<Label htmlFor="vol-writable" className="text-xs">
|
|
Writable
|
|
</Label>
|
|
</div>
|
|
<Button type="submit" size="sm" disabled={registerVolume.isPending}>
|
|
Add
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Library Paths */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Library Paths</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
{paths.isLoading ? (
|
|
<Spinner />
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Volume</TableHead>
|
|
<TableHead>Path</TableHead>
|
|
<TableHead>Ingest Dest</TableHead>
|
|
<TableHead className="w-10" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(paths.data ?? []).map((p) => {
|
|
const vol = volumeList.find((v) => v.id === p.volume_id)
|
|
return (
|
|
<TableRow key={p.id}>
|
|
<TableCell className="text-sm">
|
|
{vol?.volume_name ?? p.volume_id.slice(0, 8) + "..."}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">
|
|
{p.relative_path || "(root)"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
p.is_ingest_destination ? "default" : "secondary"
|
|
}
|
|
>
|
|
{p.is_ingest_destination ? "Yes" : "No"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={async () => {
|
|
try {
|
|
await deletePath.mutateAsync(p.id)
|
|
toast.success("Library path deleted")
|
|
} catch {
|
|
toast.error("Failed to delete path")
|
|
}
|
|
}}
|
|
>
|
|
<Trash2Icon className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
<form onSubmit={handleCreatePath} className="flex items-end gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-xs">Volume</Label>
|
|
<Select value={pathVolumeId} onValueChange={setPathVolumeId}>
|
|
<SelectTrigger className="h-8 w-44">
|
|
<SelectValue placeholder="Select volume" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{volumeList.map((v) => (
|
|
<SelectItem key={v.id} value={v.id}>
|
|
{v.volume_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-xs">Relative Path</Label>
|
|
<Input
|
|
value={pathRelative}
|
|
onChange={(e) => setPathRelative(e.target.value)}
|
|
placeholder="(empty = root)"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 pb-1">
|
|
<Checkbox
|
|
id="path-ingest"
|
|
checked={pathIngest}
|
|
onCheckedChange={(c) => setPathIngest(c === true)}
|
|
/>
|
|
<Label htmlFor="path-ingest" className="text-xs">
|
|
Ingest
|
|
</Label>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={!pathVolumeId || registerPath.isPending}
|
|
>
|
|
Add
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|