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>
|
||||
)
|
||||
}
|
||||
129
k-photos-frontend/app/(app)/albums/[id]/page.tsx
Normal file
129
k-photos-frontend/app/(app)/albums/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
k-photos-frontend/app/(app)/layout.tsx
Normal file
76
k-photos-frontend/app/(app)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
k-photos-frontend/app/(app)/page.tsx
Normal file
35
k-photos-frontend/app/(app)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
k-photos-frontend/app/(auth)/layout.tsx
Normal file
11
k-photos-frontend/app/(auth)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
k-photos-frontend/app/(auth)/login/page.tsx
Normal file
86
k-photos-frontend/app/(auth)/login/page.tsx
Normal 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't have an account?{" "}
|
||||
<Link href="/register" className="underline hover:text-foreground">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
97
k-photos-frontend/app/(auth)/register/page.tsx
Normal file
97
k-photos-frontend/app/(auth)/register/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user