- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count - frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs - openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
139 lines
4.1 KiB
TypeScript
139 lines
4.1 KiB
TypeScript
import { goto } from '$app/navigation';
|
|
import { auth, type Tokens, type User } from './stores/auth.svelte';
|
|
import type { Photo, Preferences, Tag } from './types';
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
code: string;
|
|
constructor(status: number, code: string, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
interface RequestOptions {
|
|
method?: string;
|
|
body?: unknown;
|
|
form?: FormData;
|
|
}
|
|
|
|
let refreshing: Promise<boolean> | null = null;
|
|
|
|
async function raw(path: string, opts: RequestOptions): Promise<Response> {
|
|
const headers = new Headers();
|
|
if (auth.access) headers.set('Authorization', `Bearer ${auth.access}`);
|
|
let body: BodyInit | undefined;
|
|
if (opts.form) {
|
|
body = opts.form;
|
|
} else if (opts.body !== undefined) {
|
|
headers.set('Content-Type', 'application/json');
|
|
body = JSON.stringify(opts.body);
|
|
}
|
|
return fetch(`/api${path}`, { method: opts.method ?? 'GET', headers, body });
|
|
}
|
|
|
|
async function doRefresh(): Promise<boolean> {
|
|
if (!auth.refresh) return false;
|
|
try {
|
|
const res = await fetch('/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refresh_token: auth.refresh })
|
|
});
|
|
if (!res.ok) return false;
|
|
const data = await res.json();
|
|
auth.setTokens(data.tokens as Tokens);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function api<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
let res = await raw(path, opts);
|
|
if (res.status === 401 && auth.refresh) {
|
|
if (!refreshing) {
|
|
refreshing = doRefresh().finally(() => {
|
|
refreshing = null;
|
|
});
|
|
}
|
|
const ok = await refreshing;
|
|
if (ok) res = await raw(path, opts);
|
|
}
|
|
if (!res.ok) {
|
|
let code = 'error';
|
|
let message = res.statusText;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.error) {
|
|
code = body.error.code ?? code;
|
|
message = body.error.message ?? message;
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (res.status === 401) {
|
|
auth.clear();
|
|
goto('/login');
|
|
}
|
|
throw new ApiError(res.status, code, message);
|
|
}
|
|
if (res.status === 204) return null as T;
|
|
const ct = res.headers.get('content-type') ?? '';
|
|
if (ct.includes('application/json')) return (await res.json()) as T;
|
|
return (await res.text()) as unknown as T;
|
|
}
|
|
|
|
export const authApi = {
|
|
register: (email: string, password: string, name: string) =>
|
|
api<{ user: User; tokens: Tokens }>('/auth/register', {
|
|
method: 'POST',
|
|
body: { email, password, name }
|
|
}),
|
|
login: (email: string, password: string) =>
|
|
api<{ user: User; tokens: Tokens }>('/auth/login', {
|
|
method: 'POST',
|
|
body: { email, password }
|
|
}),
|
|
logout: (refresh_token: string) =>
|
|
api('/auth/logout', { method: 'POST', body: { refresh_token } }),
|
|
me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'),
|
|
savePreferences: (preferences: Preferences) =>
|
|
api<{ preferences: Preferences }>('/auth/me/preferences', {
|
|
method: 'PATCH',
|
|
body: preferences
|
|
})
|
|
};
|
|
|
|
export const photosApi = {
|
|
deleteBatch: (ids: string[]) =>
|
|
api<{ deleted: number }>('/photo-batch/delete', { method: 'POST', body: { ids } }),
|
|
moveBatch: (ids: string[], albumId: string) =>
|
|
api<{ moved: number }>('/photo-batch/move', {
|
|
method: 'POST',
|
|
body: { ids, album_id: albumId }
|
|
}),
|
|
copyBatch: (ids: string[], albumId: string) =>
|
|
api<{ photos: Photo[] }>('/photo-batch/copy', {
|
|
method: 'POST',
|
|
body: { ids, album_id: albumId }
|
|
}),
|
|
trash: () => api<{ photos: Photo[] }>('/trash'),
|
|
trashCount: () => api<{ count: number }>('/trash/count'),
|
|
restore: (id: string, albumId?: string) =>
|
|
api<{ photo: Photo }>(`/photos/${id}/restore`, {
|
|
method: 'POST',
|
|
body: albumId ? { album_id: albumId } : {}
|
|
}),
|
|
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }),
|
|
addTag: (id: string, name: string) =>
|
|
api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
|
|
removeTag: (id: string, tagId: string) =>
|
|
api<{ photo: Photo }>(`/photos/${id}/tags/${tagId}`, { method: 'DELETE' })
|
|
};
|
|
|
|
export const tagsApi = {
|
|
list: (q = '') => api<{ tags: Tag[] }>(`/tags${q ? `?q=${encodeURIComponent(q)}` : ''}`)
|
|
};
|