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:
111
k-photos-frontend/components/add-to-album-dialog.tsx
Normal file
111
k-photos-frontend/components/add-to-album-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
k-photos-frontend/components/admin-sidebar.tsx
Normal file
57
k-photos-frontend/components/admin-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
k-photos-frontend/components/album-sidebar.tsx
Normal file
78
k-photos-frontend/components/album-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
k-photos-frontend/components/asset-picker-dialog.tsx
Normal file
103
k-photos-frontend/components/asset-picker-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
k-photos-frontend/components/auth-provider.tsx
Normal file
81
k-photos-frontend/components/auth-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
k-photos-frontend/components/date-scrubber.tsx
Normal file
145
k-photos-frontend/components/date-scrubber.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
k-photos-frontend/components/image-viewer.tsx
Normal file
183
k-photos-frontend/components/image-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
k-photos-frontend/components/metadata-sidebar.tsx
Normal file
204
k-photos-frontend/components/metadata-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
k-photos-frontend/components/photo-card.tsx
Normal file
86
k-photos-frontend/components/photo-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
k-photos-frontend/components/photo-grid.tsx
Normal file
180
k-photos-frontend/components/photo-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
k-photos-frontend/components/query-provider.tsx
Normal file
9
k-photos-frontend/components/query-provider.tsx
Normal 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>
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
107
k-photos-frontend/components/upload-dialog.tsx
Normal file
107
k-photos-frontend/components/upload-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user