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:
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