Files
ds-galo/frontend/src/lib/api.ts
T
iu 61b45b2742 feat: context menus, unified photo panel, tags, album trash and counters
- 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)
2026-10-10 22:45:41 +03:00

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)}` : ''}`)
};