Files
k-photos/k-photos-frontend/app/(app)/albums/[id]/page.tsx
Gabriel Kaszewski 957737ac9b 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
2026-06-01 01:35:43 +02:00

130 lines
3.6 KiB
TypeScript

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