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

View File

@@ -0,0 +1,129 @@
"use client"
import { useMemo, useState } from "react"
import { useParams } from "next/navigation"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import { useAlbums } from "@/hooks/use-albums"
import { groupByDate } from "@/lib/timeline"
import type { AlbumResponse, AssetResponse } from "@/lib/types"
import { PhotoGrid } from "@/components/photo-grid"
import { AssetPickerDialog } from "@/components/asset-picker-dialog"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { toast } from "sonner"
import { PlusIcon } from "lucide-react"
export default function AlbumDetailPage() {
const { id } = useParams<{ id: string }>()
const qc = useQueryClient()
const { addEntry, removeEntry } = useAlbums()
const [pickerOpen, setPickerOpen] = useState(false)
const { data: album, isLoading: albumLoading } = useQuery({
queryKey: ["album", id],
queryFn: async () => {
const { data } = await api.get<AlbumResponse>(`/albums/${id}`)
return data
},
})
const { data: assets, isLoading: assetsLoading } = useQuery({
queryKey: ["album", id, "assets"],
queryFn: async () => {
if (!album || album.asset_ids.length === 0) return []
const results = await Promise.all(
album.asset_ids.map((assetId) =>
api
.get<AssetResponse>(`/assets/${assetId}`)
.then((r) => r.data)
.catch(() => null),
),
)
return results.filter(Boolean) as AssetResponse[]
},
enabled: !!album,
})
const groups = useMemo(() => groupByDate(assets ?? []), [assets])
const existingIds = useMemo(
() => new Set(album?.asset_ids ?? []),
[album],
)
const handleRemove = async (assetId: string) => {
try {
await removeEntry({ albumId: id, assetId })
qc.invalidateQueries({ queryKey: ["album", id] })
toast.success("Removed from album")
} catch {
toast.error("Failed to remove")
}
}
const handleAddPhotos = async (assetIds: string[]) => {
let added = 0
for (const assetId of assetIds) {
try {
await addEntry({ albumId: id, assetId })
added++
} catch {
/* skip duplicates */
}
}
qc.invalidateQueries({ queryKey: ["album", id] })
toast.success(`Added ${added} photo(s)`)
}
if (albumLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
)
}
if (!album) {
return (
<div className="py-12 text-center text-muted-foreground">
Album not found
</div>
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between">
<div>
<h1 className="text-lg font-semibold">{album.title}</h1>
{album.description && (
<p className="text-sm text-muted-foreground">
{album.description}
</p>
)}
<p className="text-xs text-muted-foreground">
{album.asset_count} photos
</p>
</div>
<Button size="sm" onClick={() => setPickerOpen(true)}>
<PlusIcon className="mr-1.5 h-3.5 w-3.5" />
Add Photos
</Button>
</div>
<PhotoGrid
groups={groups}
isLoading={assetsLoading}
hasMore={false}
onLoadMore={() => {}}
onRemoveAsset={handleRemove}
/>
<AssetPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
excludeIds={existingIds}
onConfirm={handleAddPhotos}
/>
</div>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@/hooks/use-auth"
import {
SidebarProvider,
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { Separator } from "@/components/ui/separator"
import { AlbumSidebar } from "@/components/album-sidebar"
import { AdminSidebar } from "@/components/admin-sidebar"
import { UploadDialog } from "@/components/upload-dialog"
import { Spinner } from "@/components/ui/spinner"
import { CameraIcon, LogOutIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
export default function AppLayout({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated, isLoading, logout } = useAuth()
const router = useRouter()
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/login")
}
}, [isLoading, isAuthenticated, router])
if (isLoading) {
return (
<div className="flex min-h-svh items-center justify-center">
<Spinner />
</div>
)
}
if (!isAuthenticated) return null
return (
<SidebarProvider>
<Sidebar>
<SidebarHeader className="flex flex-row items-center gap-2 px-4 py-3">
<Link href="/" className="flex items-center gap-2 font-semibold">
<CameraIcon className="h-5 w-5" />
K-Photos
</Link>
</SidebarHeader>
<SidebarContent>
<AlbumSidebar />
<AdminSidebar />
</SidebarContent>
<div className="flex items-center justify-between border-t px-4 py-2">
<span className="truncate text-xs text-muted-foreground">
{user?.username}
</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
<LogOutIcon className="h-3.5 w-3.5" />
</Button>
</div>
</Sidebar>
<SidebarInset>
<header className="flex h-12 items-center gap-2 border-b px-4">
<SidebarTrigger />
<Separator orientation="vertical" className="h-4" />
<div className="flex-1" />
<UploadDialog />
</header>
<main className="flex-1 p-4">{children}</main>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { useMemo } from "react"
import { useTimeline, useDateSummary } from "@/hooks/use-timeline"
import { groupByDate } from "@/lib/timeline"
import { PhotoGrid } from "@/components/photo-grid"
import { DateScrubber } from "@/components/date-scrubber"
export default function TimelinePage() {
const { assets, isLoading, hasMore, loadMore, total } = useTimeline()
const { data: dateSummary } = useDateSummary()
const groups = useMemo(() => groupByDate(assets), [assets])
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Timeline</h1>
{total > 0 && (
<span className="text-sm text-muted-foreground">
{total} photos
</span>
)}
</div>
<div className="flex gap-1">
<PhotoGrid
groups={groups}
isLoading={isLoading}
hasMore={hasMore}
onLoadMore={() => loadMore()}
/>
<DateScrubber dates={dateSummary ?? []} />
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex min-h-svh items-center justify-center bg-background p-4">
<div className="w-full max-w-sm">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function LoginPage() {
const router = useRouter()
const { login } = useAuth()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
try {
await login(email, password)
router.push("/")
} catch {
setError("Invalid email or password")
} finally {
setLoading(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>
Enter your email to sign in to K-Photos
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="underline hover:text-foreground">
Register
</Link>
</p>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,97 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function RegisterPage() {
const router = useRouter()
const { register } = useAuth()
const [username, setUsername] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
try {
await register(username, email, password)
router.push("/")
} catch {
setError("Registration failed. Try a different email or username.")
} finally {
setLoading(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Create account</CardTitle>
<CardDescription>
Sign up to start managing your photos
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "Creating account..." : "Create account"}
</Button>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="underline hover:text-foreground">
Sign in
</Link>
</p>
</form>
</CardContent>
</Card>
)
}

View File

@@ -1,10 +1,13 @@
import { Geist, Geist_Mono, Inter } from "next/font/google"
import { Geist_Mono, Inter } from "next/font/google"
import "./globals.css"
import { ThemeProvider } from "@/components/theme-provider"
import { cn } from "@/lib/utils";
import { AuthProvider } from "@/components/auth-provider"
import { QueryProvider } from "@/components/query-provider"
import { Toaster } from "@/components/ui/sonner"
import { cn } from "@/lib/utils"
const inter = Inter({subsets:['latin'],variable:'--font-sans'})
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
const fontMono = Geist_Mono({
subsets: ["latin"],
@@ -20,10 +23,22 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)}
className={cn(
"antialiased",
fontMono.variable,
"font-sans",
inter.variable,
)}
>
<body>
<ThemeProvider>{children}</ThemeProvider>
<ThemeProvider>
<QueryProvider>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</QueryProvider>
</ThemeProvider>
</body>
</html>
)

View File

@@ -1,19 +0,0 @@
import { Button } from "@/components/ui/button"
export default function Page() {
return (
<div className="flex min-h-svh p-6">
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button className="mt-2">Button</Button>
</div>
<div className="font-mono text-xs text-muted-foreground">
(Press <kbd>d</kbd> to toggle dark mode)
</div>
</div>
</div>
)
}