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:
67
k-photos-frontend/hooks/use-albums.ts
Normal file
67
k-photos-frontend/hooks/use-albums.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
10
k-photos-frontend/hooks/use-auth.ts
Normal file
10
k-photos-frontend/hooks/use-auth.ts
Normal 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
|
||||
}
|
||||
35
k-photos-frontend/hooks/use-duplicates.ts
Normal file
35
k-photos-frontend/hooks/use-duplicates.ts
Normal 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"] }),
|
||||
})
|
||||
}
|
||||
89
k-photos-frontend/hooks/use-jobs.ts
Normal file
89
k-photos-frontend/hooks/use-jobs.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
30
k-photos-frontend/hooks/use-pipelines.ts
Normal file
30
k-photos-frontend/hooks/use-pipelines.ts
Normal 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"] }),
|
||||
})
|
||||
}
|
||||
32
k-photos-frontend/hooks/use-plugins.ts
Normal file
32
k-photos-frontend/hooks/use-plugins.ts
Normal 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"] }),
|
||||
})
|
||||
}
|
||||
71
k-photos-frontend/hooks/use-sidecars.ts
Normal file
71
k-photos-frontend/hooks/use-sidecars.ts
Normal 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")
|
||||
},
|
||||
})
|
||||
}
|
||||
83
k-photos-frontend/hooks/use-storage-admin.ts
Normal file
83
k-photos-frontend/hooks/use-storage-admin.ts
Normal 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"] }),
|
||||
})
|
||||
}
|
||||
46
k-photos-frontend/hooks/use-timeline.ts
Normal file
46
k-photos-frontend/hooks/use-timeline.ts
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
90
k-photos-frontend/hooks/use-upload.ts
Normal file
90
k-photos-frontend/hooks/use-upload.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user