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:
2026-06-01 01:35:43 +02:00
parent 49f77a78b9
commit 957737ac9b
101 changed files with 4679 additions and 109 deletions

View File

@@ -0,0 +1,131 @@
"use client"
import { useEffect, useState } from "react"
import { useDuplicates, useResolveDuplicate } from "@/hooks/use-duplicates"
import { getTokens } from "@/lib/auth"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner"
import { toast } from "sonner"
function AssetThumb({ assetId }: { assetId: string }) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
let revoke: string | null = null
const { access } = getTokens()
const headers: HeadersInit = access ? { Authorization: `Bearer ${access}` } : {}
fetch(`/api/v1/assets/${assetId}/derivatives/thumbnail_square`, { headers })
.then((r) => (r.ok ? r.blob() : Promise.reject()))
.catch(() =>
fetch(`/api/v1/assets/${assetId}/file`, { headers }).then((r) =>
r.ok ? r.blob() : Promise.reject(),
),
)
.then((blob) => {
revoke = URL.createObjectURL(blob)
setSrc(revoke)
})
.catch(() => {})
return () => {
if (revoke) URL.revokeObjectURL(revoke)
}
}, [assetId])
return src ? (
<img
src={src}
alt=""
className="h-20 w-20 shrink-0 rounded object-cover"
/>
) : (
<Skeleton className="h-20 w-20 shrink-0 rounded" />
)
}
export default function DuplicatesPage() {
const { data: groups, isLoading } = useDuplicates()
const resolve = useResolveDuplicate()
return (
<div className="flex flex-col gap-4">
<h1 className="text-lg font-semibold">Duplicate Resolution</h1>
{isLoading ? (
<Spinner />
) : (groups ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">
No duplicate groups found.
</p>
) : (
(groups ?? []).map((group) => (
<Card key={group.group_id}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<span className="font-mono">
{group.group_id.slice(0, 8)}...
</span>
<Badge variant="secondary">{group.detection_method}</Badge>
<Badge
variant={
group.status === "Pending" ? "default" : "secondary"
}
>
{group.status}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{group.candidates.map((c) => (
<div
key={c.asset_id}
className="flex gap-3 rounded border p-2"
>
<AssetThumb assetId={c.asset_id} />
<div className="flex flex-1 flex-col justify-between">
<div>
<p className="font-mono text-xs">
{c.asset_id.slice(0, 12)}...
</p>
<p className="text-xs text-muted-foreground">
{(c.similarity_score * 100).toFixed(1)}% match
</p>
</div>
<Button
size="sm"
variant="outline"
className="h-6 self-start text-xs"
disabled={resolve.isPending}
onClick={async () => {
try {
await resolve.mutateAsync({
groupId: group.group_id,
keepAssetId: c.asset_id,
})
toast.success("Resolved — kept this asset")
} catch {
toast.error("Failed to resolve")
}
}}
>
Keep
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))
)}
</div>
)
}

View File

@@ -0,0 +1,275 @@
"use client"
import { useState } from "react"
import {
useJobs,
useStartJob,
useFailJob,
useCompleteJob,
JOBS_PAGE_SIZE,
} from "@/hooks/use-jobs"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Spinner } from "@/components/ui/spinner"
import { toast } from "sonner"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
PlayIcon,
CheckIcon,
XIcon,
} from "lucide-react"
const STATUS_FILTERS = [
{ value: undefined, label: "All" },
{ value: "queued", label: "Queued" },
{ value: "running", label: "Running" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
]
function statusVariant(status: string) {
switch (status.toLowerCase()) {
case "queued":
return "secondary" as const
case "running":
return "default" as const
case "completed":
return "default" as const
case "failed":
return "destructive" as const
default:
return "secondary" as const
}
}
export default function JobsPage() {
const [filter, setFilter] = useState<string | undefined>(undefined)
const [offset, setOffset] = useState(0)
const jobs = useJobs(filter, offset)
const startJob = useStartJob()
const failJob = useFailJob()
const completeJob = useCompleteJob()
const total = jobs.data?.total ?? 0
const page = Math.floor(offset / JOBS_PAGE_SIZE) + 1
const totalPages = Math.ceil(total / JOBS_PAGE_SIZE)
const handleFilterChange = (v: string) => {
setFilter(v === "all" ? undefined : v)
setOffset(0)
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Job Queue</h1>
{total > 0 && (
<span className="text-sm text-muted-foreground">{total} total</span>
)}
</div>
<Tabs value={filter ?? "all"} onValueChange={handleFilterChange}>
<TabsList>
{STATUS_FILTERS.map((f) => (
<TabsTrigger key={f.label} value={f.value ?? "all"}>
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Card>
<CardHeader>
<CardTitle>Jobs</CardTitle>
</CardHeader>
<CardContent>
{jobs.isLoading ? (
<Spinner />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-6" />
<TableHead>ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(jobs.data?.jobs ?? []).map((job) => (
<Collapsible key={job.job_id} asChild>
<>
<TableRow>
<TableCell className="p-0 pl-2">
{job.error_message && (
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
>
<ChevronDownIcon className="h-3 w-3 transition-transform [[data-state=open]>&]:rotate-180" />
</Button>
</CollapsibleTrigger>
)}
</TableCell>
<TableCell className="font-mono text-xs">
{job.job_id.slice(0, 8)}...
</TableCell>
<TableCell className="text-sm">
{job.job_type}
</TableCell>
<TableCell>
<Badge variant={statusVariant(job.status)}>
{job.status}
</Badge>
</TableCell>
<TableCell>{job.priority}</TableCell>
<TableCell className="text-xs">
{new Date(job.created_at).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex gap-1">
{job.status.toLowerCase() === "queued" && (
<Button
size="icon"
variant="outline"
className="h-6 w-6"
title="Start"
onClick={async () => {
try {
await startJob.mutateAsync(job.job_id)
toast.success("Job started")
} catch {
toast.error("Failed to start")
}
}}
>
<PlayIcon className="h-3 w-3" />
</Button>
)}
{job.status.toLowerCase() === "running" && (
<>
<Button
size="icon"
variant="outline"
className="h-6 w-6"
title="Complete"
onClick={async () => {
try {
await completeJob.mutateAsync({
jobId: job.job_id,
result: {},
})
toast.success("Job completed")
} catch {
toast.error("Failed")
}
}}
>
<CheckIcon className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="destructive"
className="h-6 w-6"
title="Fail"
onClick={async () => {
try {
await failJob.mutateAsync({
jobId: job.job_id,
error: "Manually failed",
})
toast.success("Job failed")
} catch {
toast.error("Failed")
}
}}
>
<XIcon className="h-3 w-3" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
{job.error_message && (
<CollapsibleContent asChild>
<tr>
<td />
<td colSpan={6} className="pb-3 pt-0">
<pre className="mt-1 max-h-40 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
{job.error_message}
</pre>
</td>
</tr>
</CollapsibleContent>
)}
</>
</Collapsible>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</span>
<div className="flex gap-1">
<Button
size="icon"
variant="outline"
className="h-7 w-7"
disabled={offset === 0}
onClick={() =>
setOffset(Math.max(0, offset - JOBS_PAGE_SIZE))
}
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
disabled={offset + JOBS_PAGE_SIZE >= total}
onClick={() => setOffset(offset + JOBS_PAGE_SIZE)}
>
<ChevronRightIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,22 @@
"use client"
import { useAuth } from "@/hooks/use-auth"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const { isAdmin, isLoading } = useAuth()
const router = useRouter()
useEffect(() => {
if (!isLoading && !isAdmin) router.replace("/")
}, [isLoading, isAdmin, router])
if (isLoading || !isAdmin) return null
return <>{children}</>
}

View File

@@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { usePipelines, useConfigurePipeline } from "@/hooks/use-pipelines"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
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 { toast } from "sonner"
export default function PipelinesPage() {
const { data: pipelines, isLoading } = usePipelines()
const configure = useConfigurePipeline()
const [triggerEvent, setTriggerEvent] = useState("")
const [stepsJson, setStepsJson] = useState("[]")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const steps = JSON.parse(stepsJson)
await configure.mutateAsync({ trigger_event: triggerEvent, steps })
toast.success("Pipeline configured")
} catch {
toast.error("Failed — check JSON syntax")
}
}
return (
<div className="flex flex-col gap-6">
<h1 className="text-lg font-semibold">Pipeline Configuration</h1>
<Card>
<CardHeader>
<CardTitle>Pipelines</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Spinner />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Trigger Event</TableHead>
<TableHead>Steps</TableHead>
<TableHead>ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(pipelines ?? []).map((p) => (
<TableRow key={p.pipeline_id}>
<TableCell className="font-mono text-sm">
{p.trigger_event}
</TableCell>
<TableCell>
<Badge variant="secondary">{p.steps_count}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{p.pipeline_id.slice(0, 8)}...
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Configure Pipeline</CardTitle>
<CardDescription>
Define a processing pipeline triggered by an event
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<Label className="text-xs">Trigger Event</Label>
<Input
required
value={triggerEvent}
onChange={(e) => setTriggerEvent(e.target.value)}
placeholder="asset.ingested"
className="h-8"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs">
Steps (JSON array of {`{ plugin_id, config }`})
</Label>
<Textarea
value={stepsJson}
onChange={(e) => setStepsJson(e.target.value)}
rows={4}
className="font-mono text-xs"
/>
</div>
<Button
type="submit"
size="sm"
className="self-start"
disabled={configure.isPending}
>
Save Pipeline
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,141 @@
"use client"
import { useState } from "react"
import { usePlugins, useManagePlugin } from "@/hooks/use-plugins"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
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 { toast } from "sonner"
export default function PluginsPage() {
const { data: plugins, isLoading } = usePlugins()
const manage = useManagePlugin()
const [name, setName] = useState("")
const [pluginType, setPluginType] = useState("media_processor")
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
try {
await manage.mutateAsync({
action: "create",
name,
plugin_type: pluginType,
})
setName("")
toast.success("Plugin created")
} catch {
toast.error("Failed to create plugin")
}
}
const handleToggle = async (pluginId: string, enabled: boolean) => {
try {
await manage.mutateAsync({
action: enabled ? "enable" : "disable",
plugin_id: pluginId,
})
toast.success(enabled ? "Plugin enabled" : "Plugin disabled")
} catch {
toast.error("Failed to update plugin")
}
}
return (
<div className="flex flex-col gap-6">
<h1 className="text-lg font-semibold">Plugin Management</h1>
<Card>
<CardHeader>
<CardTitle>Installed Plugins</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Spinner />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Enabled</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(plugins ?? []).map((p) => (
<TableRow key={p.plugin_id}>
<TableCell className="font-mono text-sm">
{p.name}
</TableCell>
<TableCell>
<Badge variant="secondary">{p.plugin_type}</Badge>
</TableCell>
<TableCell>
<Switch
checked={p.is_enabled}
onCheckedChange={(checked) =>
handleToggle(p.plugin_id, checked)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Create Plugin</CardTitle>
<CardDescription>Register a new processing plugin</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="flex items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-xs">Name</Label>
<Input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-processor"
className="h-8"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs">Type</Label>
<Input
required
value={pluginType}
onChange={(e) => setPluginType(e.target.value)}
placeholder="media_processor"
className="h-8"
/>
</div>
<Button type="submit" size="sm" disabled={manage.isPending}>
Create
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,178 @@
"use client"
import { useState } from "react"
import {
useDetectChanges,
useFullExport,
useFullImport,
useExportSidecar,
useImportSidecar,
useResolveSidecarConflict,
} from "@/hooks/use-sidecars"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
export default function SidecarsPage() {
const detectChanges = useDetectChanges()
const fullExport = useFullExport()
const fullImport = useFullImport()
const exportSidecar = useExportSidecar()
const importSidecar = useImportSidecar()
const resolveConflict = useResolveSidecarConflict()
const [assetId, setAssetId] = useState("")
const [conflictPolicy, setConflictPolicy] = useState("keep_local")
return (
<div className="flex flex-col gap-6">
<h1 className="text-lg font-semibold">Sidecar Management</h1>
{/* Bulk actions */}
<Card>
<CardHeader>
<CardTitle>Bulk Operations</CardTitle>
<CardDescription>
Manage sidecar metadata across all assets
</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={detectChanges.isPending}
onClick={async () => {
try {
const res = await detectChanges.mutateAsync()
toast.success(`Detected ${res.changed_count} change(s)`)
} catch {
toast.error("Detection failed")
}
}}
>
Detect Changes
</Button>
<Button
size="sm"
variant="outline"
disabled={fullExport.isPending}
onClick={async () => {
try {
await fullExport.mutateAsync()
toast.success("Full export started")
} catch {
toast.error("Export failed")
}
}}
>
Full Export
</Button>
<Button
size="sm"
variant="outline"
disabled={fullImport.isPending}
onClick={async () => {
try {
await fullImport.mutateAsync()
toast.success("Full import started")
} catch {
toast.error("Import failed")
}
}}
>
Full Import
</Button>
</CardContent>
</Card>
{/* Per-asset */}
<Card>
<CardHeader>
<CardTitle>Per-Asset Operations</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<Label className="text-xs">Asset ID</Label>
<Input
value={assetId}
onChange={(e) => setAssetId(e.target.value)}
placeholder="uuid"
className="h-8"
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={!assetId || exportSidecar.isPending}
onClick={async () => {
try {
const res = await exportSidecar.mutateAsync(assetId)
toast.success(
<>
Exported: <Badge variant="secondary">{res.status}</Badge>
</>,
)
} catch {
toast.error("Export failed")
}
}}
>
Export
</Button>
<Button
size="sm"
variant="outline"
disabled={!assetId || importSidecar.isPending}
onClick={async () => {
try {
const res = await importSidecar.mutateAsync(assetId)
toast.success(`Import: ${res.status}`)
} catch {
toast.error("Import failed")
}
}}
>
Import
</Button>
<div className="flex items-center gap-1">
<Input
value={conflictPolicy}
onChange={(e) => setConflictPolicy(e.target.value)}
placeholder="keep_local"
className="h-8 w-32"
/>
<Button
size="sm"
variant="secondary"
disabled={!assetId || resolveConflict.isPending}
onClick={async () => {
try {
await resolveConflict.mutateAsync({
assetId,
policy: conflictPolicy,
})
toast.success("Conflict resolved")
} catch {
toast.error("Resolve failed")
}
}}
>
Resolve
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

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