Files
k-photos/k-photos-frontend/app/(app)/admin/storage/page.tsx
Gabriel Kaszewski 957737ac9b feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
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
2026-06-01 01:35:43 +02:00

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>
)
}