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,67 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import type {
AlbumResponse,
CreateAlbumRequest,
UpdateAlbumRequest,
} from "@/lib/types"
export function useAlbums() {
const qc = useQueryClient()
const query = useQuery({
queryKey: ["albums"],
queryFn: async () => {
const { data } = await api.get<AlbumResponse[]>("/albums")
return data
},
})
const create = useMutation({
mutationFn: async (title: string) => {
const body: CreateAlbumRequest = { title }
const { data } = await api.post<AlbumResponse>("/albums", body)
return data
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
})
const update = useMutation({
mutationFn: async ({ id, ...updates }: UpdateAlbumRequest & { id: string }) => {
const { data } = await api.put<AlbumResponse>(`/albums/${id}`, updates)
return data
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
})
const addEntry = useMutation({
mutationFn: async ({ albumId, assetId }: { albumId: string; assetId: string }) => {
await api.post(`/albums/${albumId}/entries`, { asset_id: assetId })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["albums"] })
qc.invalidateQueries({ queryKey: ["album"] })
},
})
const removeEntry = useMutation({
mutationFn: async ({ albumId, assetId }: { albumId: string; assetId: string }) => {
await api.delete(`/albums/${albumId}/entries/${assetId}`)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["albums"] })
qc.invalidateQueries({ queryKey: ["album"] })
},
})
return {
albums: query.data ?? [],
isLoading: query.isLoading,
createAlbum: create.mutateAsync,
updateAlbum: update.mutateAsync,
addEntry: addEntry.mutateAsync,
removeEntry: removeEntry.mutateAsync,
}
}

View File

@@ -0,0 +1,10 @@
"use client"
import { useContext } from "react"
import { AuthContext } from "@/components/auth-provider"
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error("useAuth must be used within AuthProvider")
return ctx
}

View File

@@ -0,0 +1,35 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import type { DuplicateGroupResponse } from "@/lib/types"
export function useDuplicates() {
return useQuery({
queryKey: ["admin", "duplicates"],
queryFn: async () => {
const { data } =
await api.get<DuplicateGroupResponse[]>("/duplicates")
return data
},
})
}
export function useResolveDuplicate() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
groupId,
keepAssetId,
}: {
groupId: string
keepAssetId: string
}) => {
await api.post(`/duplicates/${groupId}/resolve`, {
keep_asset_id: keepAssetId,
})
},
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "duplicates"] }),
})
}

View File

@@ -0,0 +1,89 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import type { JobListResponse, BatchProgressResponse } from "@/lib/types"
const PAGE_SIZE = 25
export function useJobs(status?: string, offset = 0) {
return useQuery({
queryKey: ["admin", "jobs", status, offset],
queryFn: async () => {
const { data } = await api.get<JobListResponse>("/jobs", {
params: { status, limit: PAGE_SIZE, offset },
})
return data
},
refetchInterval: 5000,
})
}
export { PAGE_SIZE as JOBS_PAGE_SIZE }
export function useEnqueueJob() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (body: {
job_type: string
priority?: number
payload?: Record<string, unknown>
target_asset_id?: string
batch_id?: string
}) => {
const { data } = await api.post("/jobs", body)
return data
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
})
}
export function useStartJob() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (jobId: string) => {
await api.post(`/jobs/${jobId}/start`)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
})
}
export function useCompleteJob() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
jobId,
result,
}: {
jobId: string
result: Record<string, unknown>
}) => {
await api.post(`/jobs/${jobId}/complete`, { result })
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
})
}
export function useFailJob() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ jobId, error }: { jobId: string; error: string }) => {
await api.post(`/jobs/${jobId}/fail`, { error })
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
})
}
export function useBatchProgress(batchId: string) {
return useQuery({
queryKey: ["admin", "batch", batchId],
queryFn: async () => {
const { data } = await api.get<BatchProgressResponse>(
`/jobs/batches/${batchId}`,
)
return data
},
enabled: !!batchId,
refetchInterval: 3000,
})
}

View File

@@ -0,0 +1,30 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import type { PipelineResponse } from "@/lib/types"
export function usePipelines() {
return useQuery({
queryKey: ["admin", "pipelines"],
queryFn: async () => {
const { data } = await api.get<PipelineResponse[]>("/pipelines")
return data
},
})
}
export function useConfigurePipeline() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (body: {
trigger_event: string
steps: { plugin_id: string; config: Record<string, unknown> }[]
}) => {
const { data } = await api.post<PipelineResponse>("/pipelines", body)
return data
},
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "pipelines"] }),
})
}

View File

@@ -0,0 +1,32 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import type { PluginResponse } from "@/lib/types"
export function usePlugins() {
return useQuery({
queryKey: ["admin", "plugins"],
queryFn: async () => {
const { data } = await api.get<PluginResponse[]>("/plugins")
return data
},
})
}
export function useManagePlugin() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (body: {
action: string
plugin_id?: string
name?: string
plugin_type?: string
config?: Record<string, unknown>
}) => {
const { data } = await api.post<PluginResponse>("/plugins", body)
return data
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "plugins"] }),
})
}

View File

@@ -0,0 +1,71 @@
"use client"
import { useMutation } from "@tanstack/react-query"
import api from "@/lib/api"
import type {
SidecarExportResponse,
SidecarImportResponse,
DetectChangesResponse,
} from "@/lib/types"
export function useExportSidecar() {
return useMutation({
mutationFn: async (assetId: string) => {
const { data } = await api.post<SidecarExportResponse>(
`/sidecar/export/${assetId}`,
)
return data
},
})
}
export function useImportSidecar() {
return useMutation({
mutationFn: async (assetId: string) => {
const { data } = await api.post<SidecarImportResponse>(
`/sidecar/import/${assetId}`,
)
return data
},
})
}
export function useDetectChanges() {
return useMutation({
mutationFn: async () => {
const { data } =
await api.post<DetectChangesResponse>("/sidecar/detect-changes")
return data
},
})
}
export function useResolveSidecarConflict() {
return useMutation({
mutationFn: async ({
assetId,
policy,
}: {
assetId: string
policy: string
}) => {
await api.post(`/sidecar/resolve/${assetId}`, { policy })
},
})
}
export function useFullExport() {
return useMutation({
mutationFn: async () => {
await api.post("/sidecar/full-export")
},
})
}
export function useFullImport() {
return useMutation({
mutationFn: async () => {
await api.post("/sidecar/full-import")
},
})
}

View File

@@ -0,0 +1,83 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import type { VolumeResponse, LibraryPathResponse } from "@/lib/types"
export function useVolumes() {
return useQuery({
queryKey: ["admin", "volumes"],
queryFn: async () => {
const { data } = await api.get<VolumeResponse[]>("/storage/volumes")
return data
},
})
}
export function useRegisterVolume() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (body: {
volume_name: string
uri_prefix: string
is_writable: boolean
}) => {
const { data } = await api.post<VolumeResponse>("/storage/volumes", body)
return data
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "volumes"] }),
})
}
export function useDeleteVolume() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/storage/volumes/${id}`)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "volumes"] }),
})
}
export function useLibraryPaths() {
return useQuery({
queryKey: ["admin", "library-paths"],
queryFn: async () => {
const { data } = await api.get<LibraryPathResponse[]>(
"/storage/library-paths/all",
)
return data
},
})
}
export function useRegisterLibraryPath() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (body: {
volume_id: string
relative_path: string
owner_id: string
is_ingest_destination: boolean
}) => {
const { data } = await api.post<LibraryPathResponse>(
"/storage/library-paths",
body,
)
return data
},
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "library-paths"] }),
})
}
export function useDeleteLibraryPath() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/storage/library-paths/${id}`)
},
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "library-paths"] }),
})
}

View File

@@ -0,0 +1,46 @@
"use client"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import api from "@/lib/api"
import type { TimelineResponse, DateSummaryResponse } from "@/lib/types"
const PAGE_SIZE = 40
export function useTimeline() {
const query = useInfiniteQuery({
queryKey: ["timeline"],
queryFn: async ({ pageParam = 0 }) => {
const { data } = await api.get<TimelineResponse>("/assets/timeline", {
params: { limit: PAGE_SIZE, offset: pageParam },
})
return data
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((n, p) => n + p.assets.length, 0)
return loaded < lastPage.total ? loaded : undefined
},
})
const assets = query.data?.pages.flatMap((p) => p.assets) ?? []
const total = query.data?.pages[0]?.total ?? 0
return {
assets,
total,
isLoading: query.isLoading,
hasMore: query.hasNextPage,
loadMore: query.fetchNextPage,
isFetchingMore: query.isFetchingNextPage,
}
}
export function useDateSummary() {
return useQuery({
queryKey: ["date-summary"],
queryFn: async () => {
const { data } = await api.get<DateSummaryResponse>("/assets/date-summary")
return data.dates
},
})
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import api from "@/lib/api"
import { toast } from "sonner"
interface LibraryPathResponse {
id: string
is_ingest_destination: boolean
}
interface UploadProgress {
file: string
progress: number
done: boolean
}
export function useUpload(onComplete?: () => void) {
const qc = useQueryClient()
const [uploads, setUploads] = useState<UploadProgress[]>([])
const [isUploading, setIsUploading] = useState(false)
const upload = useCallback(
async (files: File[]) => {
setIsUploading(true)
const initial = files.map((f) => ({
file: f.name,
progress: 0,
done: false,
}))
setUploads(initial)
let targetPathId: string | null = null
try {
const { data } = await api.get<LibraryPathResponse[]>(
"/storage/library-paths",
)
const dest = data.find((p) => p.is_ingest_destination)
targetPathId = dest?.id ?? data[0]?.id ?? null
} catch {
toast.error("No ingest destination configured")
setIsUploading(false)
return
}
if (!targetPathId) {
toast.error("No ingest destination configured")
setIsUploading(false)
return
}
let succeeded = 0
let failed = 0
for (let i = 0; i < files.length; i++) {
try {
const form = new FormData()
form.append("file", files[i])
form.append("target_path_id", targetPathId)
await api.post("/assets/ingest", form, {
onUploadProgress: (e) => {
const pct = e.total ? Math.round((e.loaded * 100) / e.total) : 0
setUploads((prev) =>
prev.map((u, j) => (j === i ? { ...u, progress: pct } : u)),
)
},
})
setUploads((prev) =>
prev.map((u, j) =>
j === i ? { ...u, progress: 100, done: true } : u,
),
)
succeeded++
} catch {
failed++
}
}
if (succeeded > 0) toast.success(`Uploaded ${succeeded} file(s)`)
if (failed > 0) toast.error(`${failed} upload(s) failed`)
await qc.invalidateQueries({ queryKey: ["timeline"] })
setIsUploading(false)
onComplete?.()
},
[onComplete, qc],
)
return { uploads, isUploading, upload }
}