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:
62
k-photos-frontend/lib/api.ts
Normal file
62
k-photos-frontend/lib/api.ts
Normal 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
|
||||
31
k-photos-frontend/lib/auth.ts
Normal file
31
k-photos-frontend/lib/auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
35
k-photos-frontend/lib/timeline.ts
Normal file
35
k-photos-frontend/lib/timeline.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
158
k-photos-frontend/lib/types.ts
Normal file
158
k-photos-frontend/lib/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user