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
This commit is contained in:
384
k-photos-frontend/app/(app)/admin/storage/page.tsx
Normal file
384
k-photos-frontend/app/(app)/admin/storage/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user