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
130 lines
3.6 KiB
TypeScript
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>
|
|
)
|
|
}
|