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:
131
k-photos-frontend/app/(app)/admin/duplicates/page.tsx
Normal file
131
k-photos-frontend/app/(app)/admin/duplicates/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
275
k-photos-frontend/app/(app)/admin/jobs/page.tsx
Normal file
275
k-photos-frontend/app/(app)/admin/jobs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
k-photos-frontend/app/(app)/admin/layout.tsx
Normal file
22
k-photos-frontend/app/(app)/admin/layout.tsx
Normal 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}</>
|
||||
}
|
||||
128
k-photos-frontend/app/(app)/admin/pipelines/page.tsx
Normal file
128
k-photos-frontend/app/(app)/admin/pipelines/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
k-photos-frontend/app/(app)/admin/plugins/page.tsx
Normal file
141
k-photos-frontend/app/(app)/admin/plugins/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
k-photos-frontend/app/(app)/admin/sidecars/page.tsx
Normal file
178
k-photos-frontend/app/(app)/admin/sidecars/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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