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,62 @@
import axios from "axios"
import { getTokens, setTokens, clearTokens } from "./auth"
import type { AuthResponse } from "./types"
const api = axios.create({
baseURL: "/api/v1",
})
api.interceptors.request.use((config) => {
const { access } = getTokens()
if (access) {
config.headers.Authorization = `Bearer ${access}`
}
return config
})
let refreshPromise: Promise<string> | null = null
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
original._retry = true
const { refresh } = getTokens()
if (!refresh) {
clearTokens()
window.location.href = "/login"
return Promise.reject(error)
}
if (!refreshPromise) {
refreshPromise = axios
.post<AuthResponse>("/api/v1/auth/refresh", {
refresh_token: refresh,
})
.then((res) => {
setTokens(res.data.token, res.data.refresh_token)
return res.data.token
})
.catch(() => {
clearTokens()
window.location.href = "/login"
return ""
})
.finally(() => {
refreshPromise = null
})
}
const newToken = await refreshPromise
if (!newToken) return Promise.reject(error)
original.headers.Authorization = `Bearer ${newToken}`
return api(original)
},
)
export default api

View File

@@ -0,0 +1,31 @@
const ACCESS_KEY = "k_photos_token"
const REFRESH_KEY = "k_photos_refresh"
export function getTokens() {
if (typeof window === "undefined") return { access: null, refresh: null }
return {
access: localStorage.getItem(ACCESS_KEY),
refresh: localStorage.getItem(REFRESH_KEY),
}
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem(ACCESS_KEY, access)
localStorage.setItem(REFRESH_KEY, refresh)
}
export function clearTokens() {
localStorage.removeItem(ACCESS_KEY)
localStorage.removeItem(REFRESH_KEY)
}
export function getRoleFromToken(): string | null {
const { access } = getTokens()
if (!access) return null
try {
const payload = JSON.parse(atob(access.split(".")[1]))
return payload.role ?? null
} catch {
return null
}
}

View File

@@ -0,0 +1,35 @@
import { format, parseISO } from "date-fns"
import type { AssetResponse } from "./types"
export interface DateGroup {
date: string
label: string
assets: AssetResponse[]
}
export function getPhotoDate(asset: AssetResponse): Date {
const dto = asset.metadata?.DateTimeOriginal as string | undefined
if (dto) {
const parsed = new Date(dto.replace(" ", "T"))
if (!isNaN(parsed.getTime())) return parsed
}
return parseISO(asset.created_at)
}
export function groupByDate(assets: AssetResponse[]): DateGroup[] {
const map = new Map<string, AssetResponse[]>()
for (const asset of assets) {
const d = getPhotoDate(asset)
const key = format(d, "yyyy-MM-dd")
const group = map.get(key)
if (group) group.push(asset)
else map.set(key, [asset])
}
return Array.from(map.entries()).map(([date, assets]) => ({
date,
label: format(parseISO(date), "MMMM d, yyyy"),
assets,
}))
}

View File

@@ -0,0 +1,158 @@
export interface UserResponse {
id: string
username: string
email: string
role: string
created_at: string
}
export interface AuthResponse {
token: string
refresh_token: string
user: UserResponse
}
export interface AssetResponse {
id: string
asset_type: string
mime_type: string
file_size: number
is_processed: boolean
created_at: string
metadata: Record<string, unknown>
}
export interface TimelineResponse {
assets: AssetResponse[]
total: number
}
export interface DateCountEntry {
date: string
count: number
}
export interface DateSummaryResponse {
dates: DateCountEntry[]
}
export interface AlbumResponse {
id: string
title: string
description: string
creator_id: string
asset_count: number
asset_ids: string[]
created_at: string
}
export interface IngestResponse {
asset: AssetResponse
session_id: string
}
export interface LoginRequest {
email: string
password: string
}
export interface RegisterRequest {
username: string
email: string
password: string
}
export interface CreateAlbumRequest {
title: string
}
export interface UpdateAlbumRequest {
title?: string
description?: string
}
// --- Storage Admin ---
export interface VolumeResponse {
id: string
volume_name: string
uri_prefix: string
is_writable: boolean
}
export interface LibraryPathResponse {
id: string
volume_id: string
relative_path: string
is_ingest_destination: boolean
}
// --- Processing ---
export interface JobResponse {
job_id: string
job_type: string
status: string
priority: number
created_at: string
error_message: string | null
}
export interface JobListResponse {
jobs: JobResponse[]
total: number
}
export interface BatchProgressResponse {
batch_id: string
batch_type: string
total: number
completed: number
failed: number
status: string
jobs: JobResponse[]
}
export interface PluginResponse {
plugin_id: string
name: string
plugin_type: string
is_enabled: boolean
}
export interface PipelineResponse {
pipeline_id: string
trigger_event: string
steps_count: number
}
// --- Sidecars ---
export interface SidecarExportResponse {
asset_id: string
status: string
path: string
}
export interface DetectChangesResponse {
changed_count: number
}
export interface SidecarImportResponse {
asset_id: string
status: string
}
// --- Duplicates ---
export interface DuplicateGroupResponse {
group_id: string
detection_method: string
status: string
candidates: DuplicateCandidateResponse[]
}
export interface DuplicateCandidateResponse {
asset_id: string
similarity_score: number
}