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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user