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
91 lines
2.4 KiB
TypeScript
91 lines
2.4 KiB
TypeScript
"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 }
|
|
}
|