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,111 @@
"use client"
import { useState } from "react"
import { useAlbums } from "@/hooks/use-albums"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { toast } from "sonner"
import { PlusIcon } from "lucide-react"
interface AddToAlbumDialogProps {
assetIds: string[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddToAlbumDialog({
assetIds,
open,
onOpenChange,
}: AddToAlbumDialogProps) {
const { albums, isLoading, createAlbum, addEntry } = useAlbums()
const [newTitle, setNewTitle] = useState("")
const [adding, setAdding] = useState(false)
const handleAdd = async (albumId: string) => {
setAdding(true)
try {
for (const assetId of assetIds) {
await addEntry({ albumId, assetId }).catch(() => {})
}
toast.success(`Added ${assetIds.length} photo(s) to album`)
onOpenChange(false)
} catch {
toast.error("Failed to add to album")
} finally {
setAdding(false)
}
}
const handleCreateAndAdd = async () => {
if (!newTitle.trim()) return
setAdding(true)
try {
const album = await createAlbum(newTitle.trim())
for (const assetId of assetIds) {
await addEntry({ albumId: album.id, assetId }).catch(() => {})
}
setNewTitle("")
toast.success(`Created album and added ${assetIds.length} photo(s)`)
onOpenChange(false)
} catch {
toast.error("Failed")
} finally {
setAdding(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Add to Album</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto">
{isLoading ? (
<Spinner />
) : (
albums.map((album) => (
<Button
key={album.id}
variant="outline"
className="justify-start"
disabled={adding}
onClick={() => handleAdd(album.id)}
>
{album.title}
<span className="ml-auto text-xs text-muted-foreground">
{album.asset_count}
</span>
</Button>
))
)}
</div>
<div className="flex gap-2 border-t pt-3">
<Input
placeholder="New album name"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateAndAdd()}
className="h-8"
/>
<Button
size="sm"
disabled={!newTitle.trim() || adding}
onClick={handleCreateAndAdd}
>
<PlusIcon className="mr-1 h-3 w-3" />
Create
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useAuth } from "@/hooks/use-auth"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar"
import {
HardDriveIcon,
ListIcon,
PlugIcon,
WorkflowIcon,
FileTextIcon,
CopyIcon,
} from "lucide-react"
const ADMIN_LINKS = [
{ href: "/admin/storage", label: "Storage", icon: HardDriveIcon },
{ href: "/admin/jobs", label: "Jobs", icon: ListIcon },
{ href: "/admin/plugins", label: "Plugins", icon: PlugIcon },
{ href: "/admin/pipelines", label: "Pipelines", icon: WorkflowIcon },
{ href: "/admin/sidecars", label: "Sidecars", icon: FileTextIcon },
{ href: "/admin/duplicates", label: "Duplicates", icon: CopyIcon },
]
export function AdminSidebar() {
const { isAdmin } = useAuth()
const pathname = usePathname()
if (!isAdmin) return null
return (
<SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{ADMIN_LINKS.map(({ href, label, icon: Icon }) => (
<SidebarMenuItem key={href}>
<SidebarMenuButton asChild isActive={pathname === href}>
<Link href={href}>
<Icon className="h-4 w-4" />
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,78 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useAlbums } from "@/hooks/use-albums"
import { ImageIcon, PlusIcon } from "lucide-react"
export function AlbumSidebar() {
const pathname = usePathname()
const { albums, createAlbum } = useAlbums()
const [isCreating, setIsCreating] = useState(false)
const [newTitle, setNewTitle] = useState("")
const handleCreate = async () => {
if (!newTitle.trim()) return
await createAlbum(newTitle.trim()).catch(() => {})
setNewTitle("")
setIsCreating(false)
}
return (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center justify-between">
Albums
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setIsCreating(!isCreating)}
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</SidebarGroupLabel>
<SidebarGroupContent>
{isCreating && (
<div className="px-2 pb-2">
<Input
autoFocus
placeholder="Album title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate()
if (e.key === "Escape") setIsCreating(false)
}}
/>
</div>
)}
<SidebarMenu>
{albums.map((album) => (
<SidebarMenuItem key={album.id}>
<SidebarMenuButton
asChild
isActive={pathname === `/albums/${album.id}`}
>
<Link href={`/albums/${album.id}`}>
<ImageIcon className="h-4 w-4" />
<span>{album.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,103 @@
"use client"
import { useState, useCallback } from "react"
import { useTimeline } from "@/hooks/use-timeline"
import { PhotoCard } from "./photo-card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Spinner } from "@/components/ui/spinner"
interface AssetPickerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
excludeIds?: Set<string>
onConfirm: (assetIds: string[]) => void
}
export function AssetPickerDialog({
open,
onOpenChange,
excludeIds,
onConfirm,
}: AssetPickerDialogProps) {
const { assets, isLoading, hasMore, loadMore } = useTimeline()
const [selected, setSelected] = useState<Set<string>>(new Set())
const toggle = useCallback((id: string, sel: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (sel) next.add(id)
else next.delete(id)
return next
})
}, [])
const filtered = excludeIds
? assets.filter((a) => !excludeIds.has(a.id))
: assets
const handleConfirm = () => {
onConfirm(Array.from(selected))
setSelected(new Set())
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) setSelected(new Set())
onOpenChange(o)
}}
>
<DialogContent className="flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Select Photos</DialogTitle>
</DialogHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="grid grid-cols-3 gap-1 sm:grid-cols-4 md:grid-cols-5">
{filtered.map((asset) => (
<PhotoCard
key={asset.id}
asset={asset}
selectable
selected={selected.has(asset.id)}
onSelect={(sel) => toggle(asset.id, sel)}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center py-3">
<Button
size="sm"
variant="ghost"
onClick={() => loadMore()}
disabled={isLoading}
>
{isLoading ? <Spinner /> : "Load more"}
</Button>
</div>
)}
</ScrollArea>
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">
{selected.size} selected
</span>
<Button
size="sm"
disabled={selected.size === 0}
onClick={handleConfirm}
>
Add to Album
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,81 @@
"use client"
import { createContext, useState, useEffect, useCallback, type ReactNode } from "react"
import api from "@/lib/api"
import { getTokens, setTokens, clearTokens } from "@/lib/auth"
import type { AuthResponse, UserResponse } from "@/lib/types"
interface AuthContextValue {
user: UserResponse | null
isAuthenticated: boolean
isAdmin: boolean
isLoading: boolean
login: (email: string, password: string) => Promise<void>
register: (username: string, email: string, password: string) => Promise<void>
logout: () => void
}
export const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const { access } = getTokens()
if (!access) {
setIsLoading(false)
return
}
api
.get<UserResponse>("/auth/me")
.then((res) => setUser(res.data))
.catch(() => clearTokens())
.finally(() => setIsLoading(false))
}, [])
const login = useCallback(async (email: string, password: string) => {
const { data } = await api.post<AuthResponse>("/auth/login", {
email,
password,
})
setTokens(data.token, data.refresh_token)
setUser(data.user)
}, [])
const register = useCallback(
async (username: string, email: string, password: string) => {
const { data } = await api.post<AuthResponse>("/auth/register", {
username,
email,
password,
})
setTokens(data.token, data.refresh_token)
setUser(data.user)
},
[],
)
const logout = useCallback(() => {
api.post("/auth/logout").catch(() => {})
clearTokens()
setUser(null)
window.location.href = "/login"
}, [])
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isAdmin: user?.role === "admin",
isLoading,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}

View File

@@ -0,0 +1,145 @@
"use client"
import { useEffect, useState, useMemo, useCallback, useRef } from "react"
import { format, parseISO } from "date-fns"
import type { DateCountEntry } from "@/lib/types"
interface DateScrubberProps {
dates: DateCountEntry[]
}
interface ScrubberEntry {
label: string
date: string
dateId: string
}
function findVisibleDateId(): string | null {
const headers = document.querySelectorAll<HTMLElement>("[data-date]")
const viewportTop = window.scrollY + window.innerHeight * 0.15
let best: HTMLElement | null = null
for (const h of headers) {
if (h.offsetTop <= viewportTop) best = h
else break
}
return best?.id ?? headers[0]?.id ?? null
}
export function DateScrubber({ dates }: DateScrubberProps) {
const [activeDate, setActiveDate] = useState<string | null>(null)
const scrollingRef = useRef(false)
const entries = useMemo<ScrubberEntry[]>(() => {
const compact = dates.length > 30
let lastYear = ""
let lastMonth = ""
const result: ScrubberEntry[] = []
for (const { date } of dates) {
const d = parseISO(date)
const monthKey = format(d, "yyyy-MM")
if (compact && monthKey === lastMonth) continue
lastMonth = monthKey
const year = format(d, "yyyy")
const showYear = year !== lastYear
lastYear = year
const label = compact
? showYear
? format(d, "MMM yyyy")
: format(d, "MMM")
: showYear
? format(d, "MMM d, yyyy")
: format(d, "MMM d")
result.push({ label, date, dateId: `date-${date}` })
}
return result
}, [dates])
useEffect(() => {
let raf = 0
const onScroll = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
if (!scrollingRef.current) {
setActiveDate(findVisibleDateId())
}
})
}
window.addEventListener("scroll", onScroll, { passive: true })
onScroll()
return () => {
window.removeEventListener("scroll", onScroll)
cancelAnimationFrame(raf)
}
}, [])
const scrollToDate = useCallback((dateId: string) => {
const el = document.getElementById(dateId)
if (!el) return
scrollingRef.current = true
setActiveDate(dateId)
el.scrollIntoView({ behavior: "smooth", block: "start" })
setTimeout(() => {
scrollingRef.current = false
}, 800)
}, [])
const handleClick = useCallback(
(entry: ScrubberEntry) => {
const el = document.getElementById(entry.dateId)
if (el) {
scrollToDate(entry.dateId)
return
}
const headers = Array.from(
document.querySelectorAll<HTMLElement>("[data-date]"),
)
let closest: HTMLElement | null = null
for (const h of headers) {
const d = h.dataset.date ?? ""
if (d >= entry.date) {
closest = h
break
}
}
if (!closest) closest = headers[headers.length - 1] ?? null
if (closest) {
scrollingRef.current = true
setActiveDate(entry.dateId)
closest.scrollIntoView({ behavior: "smooth", block: "start" })
setTimeout(() => {
scrollingRef.current = false
}, 800)
}
},
[scrollToDate],
)
if (entries.length < 1) return null
return (
<div className="sticky top-0 flex h-[calc(100svh-7rem)] w-8 shrink-0 flex-col items-center justify-start gap-0.5 overflow-y-auto py-2">
{entries.map((entry) => (
<button
key={entry.dateId}
onClick={() => handleClick(entry)}
className={`w-full rounded px-0.5 py-0.5 text-center text-[9px] leading-tight transition-colors ${
activeDate === entry.dateId
? "font-semibold text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{entry.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,183 @@
"use client"
import { useEffect, useState, useCallback, useRef } from "react"
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"
import type { AssetResponse } from "@/lib/types"
import { getTokens } from "@/lib/auth"
import { MetadataSidebar } from "./metadata-sidebar"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
XIcon,
ZoomInIcon,
ZoomOutIcon,
Maximize2Icon,
InfoIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
interface ImageViewerProps {
assets: AssetResponse[]
initialIndex: number
onClose: () => void
}
export function ImageViewer({ assets, initialIndex, onClose }: ImageViewerProps) {
const [index, setIndex] = useState(initialIndex)
const [src, setSrc] = useState<string | null>(null)
const [sidebarOpen, setSidebarOpen] = useState(false)
const prevBlobRef = useRef<string | null>(null)
const asset = assets[index]
const hasPrev = index > 0
const hasNext = index < assets.length - 1
useEffect(() => {
if (prevBlobRef.current) URL.revokeObjectURL(prevBlobRef.current)
setSrc(null)
const { access } = getTokens()
fetch(`/api/v1/assets/${asset.id}/file`, {
headers: access ? { Authorization: `Bearer ${access}` } : {},
})
.then((r) => (r.ok ? r.blob() : Promise.reject()))
.then((blob) => {
const url = URL.createObjectURL(blob)
prevBlobRef.current = url
setSrc(url)
})
.catch(() => {})
return () => {
if (prevBlobRef.current) {
URL.revokeObjectURL(prevBlobRef.current)
prevBlobRef.current = null
}
}
}, [asset.id])
const goPrev = useCallback(() => {
if (hasPrev) setIndex((i) => i - 1)
}, [hasPrev])
const goNext = useCallback(() => {
if (hasNext) setIndex((i) => i + 1)
}, [hasNext])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose()
if (e.key === "ArrowLeft") goPrev()
if (e.key === "ArrowRight") goNext()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [onClose, goPrev, goNext])
return (
<div className="fixed inset-0 z-50 flex bg-black">
{/* Main image area */}
<div className="relative flex flex-1 items-center justify-center">
<TransformWrapper
key={asset.id}
initialScale={1}
minScale={0.5}
maxScale={10}
centerOnInit
wheel={{ step: 0.1 }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
{/* Toolbar */}
<div className="absolute top-0 right-0 left-0 z-10 flex items-center justify-between p-3">
<div />
<div className="flex items-center gap-1">
<ToolbarButton onClick={() => zoomIn()} icon={<ZoomInIcon />} />
<ToolbarButton onClick={() => zoomOut()} icon={<ZoomOutIcon />} />
<ToolbarButton onClick={() => resetTransform()} icon={<Maximize2Icon />} />
<ToolbarButton
onClick={() => setSidebarOpen(!sidebarOpen)}
icon={<InfoIcon />}
active={sidebarOpen}
/>
<ToolbarButton onClick={onClose} icon={<XIcon />} />
</div>
</div>
{/* Image */}
<TransformComponent
wrapperStyle={{ width: "100%", height: "100%" }}
contentStyle={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{src ? (
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
draggable={false}
/>
) : (
<Skeleton className="h-96 w-96 rounded-lg" />
)}
</TransformComponent>
</>
)}
</TransformWrapper>
{/* Navigation arrows */}
{hasPrev && (
<button
onClick={goPrev}
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
)}
{hasNext && (
<button
onClick={goNext}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
)}
</div>
{/* Metadata sidebar */}
{sidebarOpen && (
<MetadataSidebar
asset={asset}
onClose={() => setSidebarOpen(false)}
/>
)}
</div>
)
}
function ToolbarButton({
onClick,
icon,
active,
}: {
onClick: () => void
icon: React.ReactNode
active?: boolean
}) {
return (
<Button
variant="ghost"
size="icon"
onClick={onClick}
className={`h-8 w-8 text-white hover:bg-white/20 ${active ? "bg-white/20" : ""}`}
>
<span className="h-4 w-4">{icon}</span>
</Button>
)
}

View File

@@ -0,0 +1,204 @@
"use client"
import type { AssetResponse } from "@/lib/types"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import { ChevronDownIcon, XIcon } from "lucide-react"
interface MetadataSidebarProps {
asset: AssetResponse
onClose: () => void
}
const CAMERA_KEYS = [
"Make",
"Model",
"FocalLength",
"FocalLengthIn35mmFilm",
"FNumber",
"ExposureTime",
"ISOSpeedRatings",
"ExposureMode",
"ExposureProgram",
"MeteringMode",
"Flash",
"WhiteBalanceMode",
"LightSource",
]
const GPS_KEYS = ["GPSInfo", "GPSLatitude", "GPSLongitude", "GPSAltitude"]
const HIDDEN_KEYS = [
"file_size_bytes",
"mime_type",
"ExifImageWidth",
"ExifImageHeight",
...CAMERA_KEYS,
...GPS_KEYS,
]
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatExposure(val: string): string {
const match = val.match(/^(\d+)\/(\d+)/)
if (!match) return val
const num = parseInt(match[1])
const den = parseInt(match[2])
if (den === 0) return val
const result = num / den
return result < 1 ? `1/${Math.round(1 / result)}s` : `${result}s`
}
function formatFocalLength(val: string): string {
const match = val.match(/^(\d+)\/(\d+)/)
if (!match) return val
return `${Math.round(parseInt(match[1]) / parseInt(match[2]))}mm`
}
function formatFNumber(val: string): string {
const match = val.match(/^(\d+)\/(\d+)/)
if (!match) return val
return `f/${(parseInt(match[1]) / parseInt(match[2])).toFixed(1)}`
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4 py-0.5 text-xs">
<span className="shrink-0 text-zinc-400">{label}</span>
<span className="truncate text-right text-zinc-200">{value}</span>
</div>
)
}
function Section({
title,
defaultOpen = true,
children,
}: {
title: string
defaultOpen?: boolean
children: React.ReactNode
}) {
return (
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-xs font-medium text-zinc-300 hover:text-white">
{title}
<ChevronDownIcon className="h-3.5 w-3.5 transition-transform [[data-state=closed]>&]:rotate-(-90)" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pb-3">{children}</div>
</CollapsibleContent>
</Collapsible>
)
}
export function MetadataSidebar({ asset, onClose }: MetadataSidebarProps) {
const meta = asset.metadata
const width = meta.ExifImageWidth as string | undefined
const height = meta.ExifImageHeight as string | undefined
const cameraEntries = CAMERA_KEYS.filter((k) => meta[k] != null).map(
(k) => {
let val = String(meta[k])
if (k === "ExposureTime") val = formatExposure(val)
if (k === "FocalLength") val = formatFocalLength(val)
if (k === "FNumber") val = formatFNumber(val)
return [k, val] as const
},
)
const gpsEntries = GPS_KEYS.filter((k) => meta[k] != null)
const remainingEntries = Object.entries(meta).filter(
([k]) => !HIDDEN_KEYS.includes(k),
)
return (
<div className="flex w-80 shrink-0 flex-col border-l border-zinc-800 bg-zinc-950/90 backdrop-blur">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium text-zinc-200">Details</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-white/10"
onClick={onClose}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
<Separator className="bg-zinc-800" />
<ScrollArea className="flex-1 px-4">
{/* File Info */}
<Section title="File Info">
<div className="flex items-center gap-2 pb-1">
<Badge variant="secondary" className="text-xs">
{asset.asset_type}
</Badge>
<span className="text-xs text-zinc-400">{asset.mime_type}</span>
</div>
<MetaRow label="Size" value={formatBytes(asset.file_size)} />
{width && height && (
<MetaRow label="Dimensions" value={`${width} × ${height}`} />
)}
<MetaRow
label="Created"
value={new Date(asset.created_at).toLocaleString()}
/>
<MetaRow
label="Processed"
value={asset.is_processed ? "Yes" : "No"}
/>
</Section>
{/* Camera */}
{cameraEntries.length > 0 && (
<>
<Separator className="bg-zinc-800" />
<Section title="Camera">
{cameraEntries.map(([k, v]) => (
<MetaRow key={k} label={k} value={v} />
))}
</Section>
</>
)}
{/* Location */}
{gpsEntries.length > 0 && (
<>
<Separator className="bg-zinc-800" />
<Section title="Location">
{gpsEntries.map((k) => (
<MetaRow key={k} label={k} value={String(meta[k])} />
))}
</Section>
</>
)}
{/* All Metadata */}
{remainingEntries.length > 0 && (
<>
<Separator className="bg-zinc-800" />
<Section title="All Metadata" defaultOpen={false}>
{remainingEntries.map(([k, v]) => (
<MetaRow key={k} label={k} value={String(v)} />
))}
</Section>
</>
)}
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,86 @@
"use client"
import { useEffect, useState } from "react"
import type { AssetResponse } from "@/lib/types"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { getTokens } from "@/lib/auth"
import { ImageIcon } from "lucide-react"
interface PhotoCardProps {
asset: AssetResponse
selected?: boolean
selectable?: boolean
onClick?: () => void
onSelect?: (selected: boolean) => void
}
export function PhotoCard({
asset,
selected,
selectable,
onClick,
onSelect,
}: PhotoCardProps) {
const [src, setSrc] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let revoke: string | null = null
setFailed(false)
const { access } = getTokens()
const headers: HeadersInit = access
? { Authorization: `Bearer ${access}` }
: {}
fetch(`/api/v1/assets/${asset.id}/derivatives/thumbnail_square`, {
headers,
})
.then((r) => (r.ok ? r.blob() : Promise.reject()))
.then((blob) => {
revoke = URL.createObjectURL(blob)
setSrc(revoke)
})
.catch(() => setFailed(true))
return () => {
if (revoke) URL.revokeObjectURL(revoke)
}
}, [asset.id])
return (
<div
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-md bg-muted ${selected ? "ring-2 ring-primary ring-offset-2" : ""}`}
onClick={selectable ? () => onSelect?.(!selected) : onClick}
>
{src ? (
<img
src={src}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : failed ? (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground/40" />
</div>
) : (
<Skeleton className="h-full w-full" />
)}
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/30" />
{selectable && (
<div className="absolute top-1.5 left-1.5">
<Checkbox
checked={selected}
onCheckedChange={(c) => onSelect?.(c === true)}
onClick={(e) => e.stopPropagation()}
className="border-white bg-black/30"
/>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 translate-y-full p-2 transition-transform group-hover:translate-y-0">
<Badge variant="secondary" className="text-xs">
{asset.asset_type}
</Badge>
</div>
</div>
)
}

View File

@@ -0,0 +1,180 @@
"use client"
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import type { AssetResponse } from "@/lib/types"
import type { DateGroup } from "@/lib/timeline"
import { PhotoCard } from "./photo-card"
import { ImageViewer } from "./image-viewer"
import { AddToAlbumDialog } from "./add-to-album-dialog"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { ImagePlusIcon, XIcon, CheckSquareIcon } from "lucide-react"
interface PhotoGridProps {
groups: DateGroup[]
isLoading: boolean
hasMore: boolean
onLoadMore: () => void
onRemoveAsset?: (assetId: string) => void
}
export function PhotoGrid({
groups,
isLoading,
hasMore,
onLoadMore,
onRemoveAsset,
}: PhotoGridProps) {
const sentinelRef = useRef<HTMLDivElement>(null)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selecting, setSelecting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [albumDialogOpen, setAlbumDialogOpen] = useState(false)
const allAssets = useMemo(
() => groups.flatMap((g) => g.assets),
[groups],
)
const toggleSelect = useCallback((id: string, selected: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (selected) next.add(id)
else next.delete(id)
return next
})
}, [])
const exitSelection = useCallback(() => {
setSelecting(false)
setSelectedIds(new Set())
}, [])
useEffect(() => {
const el = sentinelRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onLoadMore()
},
{ rootMargin: "200px" },
)
observer.observe(el)
return () => observer.disconnect()
}, [onLoadMore])
if (allAssets.length === 0 && !isLoading) {
return (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
No photos yet. Upload some to get started.
</div>
)
}
let flatIndex = 0
return (
<div className="flex flex-1 flex-col">
{/* Selection toolbar */}
{selecting && (
<div className="sticky top-0 z-20 flex items-center gap-2 rounded-md border bg-background px-3 py-2 shadow-sm">
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<div className="flex-1" />
<Button
size="sm"
variant="outline"
disabled={selectedIds.size === 0}
onClick={() => setAlbumDialogOpen(true)}
>
<ImagePlusIcon className="mr-1.5 h-3.5 w-3.5" />
Add to Album
</Button>
{onRemoveAsset && selectedIds.size > 0 && (
<Button
size="sm"
variant="destructive"
onClick={() => {
selectedIds.forEach((id) => onRemoveAsset(id))
exitSelection()
}}
>
Remove
</Button>
)}
<Button size="sm" variant="ghost" onClick={exitSelection}>
<XIcon className="h-3.5 w-3.5" />
</Button>
</div>
)}
{!selecting && allAssets.length > 0 && (
<div className="flex justify-end">
<Button
size="sm"
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => setSelecting(true)}
>
<CheckSquareIcon className="mr-1 h-3.5 w-3.5" />
Select
</Button>
</div>
)}
<div className="flex flex-col gap-4">
{groups.map((group) => {
const startIndex = flatIndex
flatIndex += group.assets.length
return (
<div key={group.date}>
<h2
id={`date-${group.date}`}
data-date={group.date}
className="sticky top-0 z-10 bg-background/80 py-1.5 text-sm font-medium backdrop-blur"
>
{group.label}
</h2>
<div className="grid grid-cols-2 gap-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{group.assets.map((asset, j) => (
<PhotoCard
key={asset.id}
asset={asset}
selectable={selecting}
selected={selectedIds.has(asset.id)}
onSelect={(sel) => toggleSelect(asset.id, sel)}
onClick={() => setSelectedIndex(startIndex + j)}
/>
))}
</div>
</div>
)
})}
{hasMore && <div ref={sentinelRef} className="h-1" />}
{isLoading && (
<div className="flex justify-center py-4">
<Spinner />
</div>
)}
</div>
{selectedIndex !== null && (
<ImageViewer
assets={allAssets}
initialIndex={selectedIndex}
onClose={() => setSelectedIndex(null)}
/>
)}
<AddToAlbumDialog
assetIds={Array.from(selectedIds)}
open={albumDialogOpen}
onOpenChange={(open) => {
setAlbumDialogOpen(open)
if (!open) exitSelection()
}}
/>
</div>
)
}

View File

@@ -0,0 +1,9 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useState, type ReactNode } from "react"
export function QueryProvider({ children }: { children: ReactNode }) {
const [client] = useState(() => new QueryClient())
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}

View File

@@ -87,6 +87,7 @@ function Calendar({
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
// @ts-expect-error react-day-picker v10 type mismatch
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(

View File

@@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 overflow-y-auto rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}

View File

@@ -0,0 +1,107 @@
"use client"
import { useState, useCallback, useRef, type DragEvent } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { useUpload } from "@/hooks/use-upload"
import { UploadIcon } from "lucide-react"
interface UploadDialogProps {
onComplete?: () => void
children?: React.ReactNode
}
export function UploadDialog({ onComplete, children }: UploadDialogProps) {
const [open, setOpen] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const { uploads, isUploading, upload } = useUpload(() => {
onComplete?.()
setOpen(false)
})
const handleFiles = useCallback(
(files: FileList | null) => {
if (!files || files.length === 0) return
upload(Array.from(files))
},
[upload],
)
const onDrop = useCallback(
(e: DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
},
[handleFiles],
)
const onDragOver = useCallback((e: DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const onDragLeave = useCallback(() => setIsDragging(false), [])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children ?? (
<Button size="sm">
<UploadIcon className="mr-2 h-4 w-4" />
Upload
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload Photos</DialogTitle>
</DialogHeader>
<div
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onClick={() => inputRef.current?.click()}
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors ${isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}`}
>
<UploadIcon className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Drag & drop files here, or click to browse
</p>
<input
ref={inputRef}
type="file"
multiple
accept="image/*,video/*"
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
{uploads.length > 0 && (
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto">
{uploads.map((u) => (
<div key={u.file} className="flex flex-col gap-1">
<span className="truncate text-xs">{u.file}</span>
<Progress value={u.progress} className="h-1.5" />
</div>
))}
</div>
)}
{isUploading && (
<p className="text-center text-xs text-muted-foreground">
Uploading...
</p>
)}
</DialogContent>
</Dialog>
)
}