/
kan64
/
spreadsheet-lab
Обзор
Документация
Войти
/
kan64
/
spreadsheet-lab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
client/src/utils/api.ts
649 строк
18 KB
Anton Kravchenkov
feat: fix
30 мар 2026, 23:14
30 мар 2026, 23:14
b5f409e
Код
Авторство
О чём код?
const API_BASE = '/api'; export function isUnauthorizedError(err: unknown): boolean { return err instanceof Error && err.message === 'UNAUTHORIZED'; } export function handleUnauthorized(): void { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_username'); localStorage.removeItem('auth_avatar_color'); window.location.href = '/auth'; } function getToken(): string | null { return localStorage.getItem('auth_token'); } function authHeaders(): Record<string, string> { const token = getToken(); const headers: Record<string, string> = { 'Content-Type': 'application/json' }; if (token) { headers['Authorization'] = `Bearer ${token}`; } return headers; } export interface AuthResponseDto { token: string; username: string; avatarColor?: string; } export async function apiRegister(username: string, password: string): Promise<AuthResponseDto> { let res: Response; try { res = await fetchWithTimeout(`${API_BASE}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); } catch (err) { if (err instanceof Error && err.name === 'AbortError') { throw new Error('Сервер не отвечает. Проверьте, что бэкенд запущен.'); } throw err; } if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.detail || `Ошибка регистрации: ${res.status}`); } return res.json(); } const AUTH_TIMEOUT_MS = 15000; const API_TIMEOUT_MS = 15000; async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = AUTH_TIMEOUT_MS): Promise<Response> { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...options, signal: controller.signal }); return res; } finally { clearTimeout(id); } } export async function apiLogin(username: string, password: string): Promise<AuthResponseDto> { let res: Response; try { res = await fetchWithTimeout(`${API_BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); } catch (err) { if (err instanceof Error && err.name === 'AbortError') { throw new Error('Сервер не отвечает. Проверьте, что бэкенд запущен.'); } throw err; } if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.detail || `Неверный логин или пароль`); } return res.json(); } export interface SheetCellBorderDto { color: string; style?: string; width?: number; } export interface SheetCellStyleDto { backgroundColor?: string; fontWeight?: string; fontStyle?: string; textDecoration?: string; color?: string; fontSize?: string | number; textAlign?: string; verticalAlign?: string; textOrientation?: string; borderTop?: SheetCellBorderDto; borderRight?: SheetCellBorderDto; borderBottom?: SheetCellBorderDto; borderLeft?: SheetCellBorderDto; wrapText?: boolean; } export interface TextRunDto { text: string; bold?: boolean; italic?: boolean; underline?: boolean; color?: string; link?: string; } export interface SheetCellDto { id: string; tabId?: string | null; rowIndex: number; colIndex: number; value: string; style?: SheetCellStyleDto; richText?: TextRunDto[]; version?: number; createdAt?: string; updatedAt?: string; } export interface MergedCellRangeDto { start: { row: number; col: number }; end: { row: number; col: number }; } export interface TabMetaDto { id: string; name: string; order: number; } export interface TabLayoutDto { mergedCells?: MergedCellRangeDto[]; columnWidths?: Record<string, number>; rowHeights?: Record<string, number>; frozenRows?: number; frozenCols?: number; } export interface SheetLayoutDto { mergedCells?: MergedCellRangeDto[]; columnWidths?: Record<string, number>; rowHeights?: Record<string, number>; frozenRows?: number; frozenCols?: number; tabs?: TabMetaDto[]; tabLayouts?: Record<string, TabLayoutDto>; } export interface SheetResponseDto { id: string; name: string; publicSheet: boolean; ownerUsername: string | null; role: string; cells: SheetCellDto[]; layout?: SheetLayoutDto | null; createdAt: string; updatedAt: string; cellsPage?: number | null; cellsSize?: number | null; cellsTotalElements?: number | null; cellsTotalPages?: number | null; } export interface SheetListItem { id: string; name: string; publicSheet: boolean; ownerUsername: string | null; role: string; createdAt: string; updatedAt: string; } export interface SheetPermissionDto { id: string; userId: string; username: string; avatarColor?: string; permission: 'READ' | 'EDIT' | 'OWNER'; createdAt: string; } export interface PageResponse<T> { content: T[]; totalElements: number; totalPages: number; number: number; size: number; } export async function apiGetSheets( page = 0, size = 20, sort = 'createdAt,desc', ): Promise<PageResponse<SheetListItem>> { const params = new URLSearchParams({ page: String(page), size: String(size), sort }); const res = await fetch(`${API_BASE}/sheets?${params}`, { headers: authHeaders() }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка загрузки таблиц: ${res.status}`); } return res.json(); } export async function apiGetSheet(id: string, page?: number, size?: number, cells = true): Promise<SheetResponseDto> { const params = new URLSearchParams(); if (page !== undefined) params.set('page', String(page)); if (size !== undefined) params.set('size', String(size)); if (!cells) params.set('cells', 'false'); const qs = params.toString(); const url = `${API_BASE}/sheets/${id}${qs ? `?${qs}` : ''}`; const res = await fetchWithTimeout(url, { headers: authHeaders() }, API_TIMEOUT_MS); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка загрузки таблицы: ${res.status}`); } return res.json(); } /** Fetches sheet metadata and layout without cells (for lazy loading) */ export async function apiGetSheetMetadata(id: string): Promise<SheetResponseDto> { return apiGetSheet(id, undefined, undefined, false); } /** Fetches cells in a given range for a tab */ export async function apiGetSheetCellsRange( sheetId: string, tabId: string, startRow: number, endRow: number, startCol: number, endCol: number, limit = 1000, signal?: AbortController['signal'], ): Promise<SheetCellDto[]> { const params = new URLSearchParams({ tabId, startRow: String(startRow), endRow: String(endRow), startCol: String(startCol), endCol: String(endCol), limit: String(limit), }); const res = await fetch(`${API_BASE}/sheets/${sheetId}/cells?${params}`, { headers: authHeaders(), signal, }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка загрузки ячеек: ${res.status}`); } return res.json(); } /** Fetches sheet with all cells by loading paginated pages and merging */ export async function apiGetSheetFull(id: string): Promise<SheetResponseDto> { const first = await apiGetSheet(id, 0); const totalPages = first.cellsTotalPages ?? 1; const pageSize = first.cellsSize ?? 100; if (totalPages <= 1) return first; const cells = [...first.cells]; const promises: Promise<SheetResponseDto>[] = []; for (let p = 1; p < totalPages; p++) { promises.push(apiGetSheet(id, p, pageSize)); } const pages = await Promise.all(promises); for (const page of pages) { cells.push(...page.cells); } return { ...first, cells }; } export async function apiCreateSheet(name: string): Promise<SheetResponseDto> { const res = await fetch(`${API_BASE}/sheets`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ name }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка создания таблицы: ${res.status}`); } return res.json(); } export interface CellResponseDto { id: string; sheetId: string; rowIndex: number; colIndex: number; value: string; } export async function apiUpsertCell( sheetId: string, rowIndex: number, colIndex: number, value: string, style?: SheetCellStyleDto | null, richText?: TextRunDto[] | null, tabId?: string | null, ): Promise<CellResponseDto> { const body: Record<string, unknown> = { rowIndex, colIndex, value }; if (tabId) { body.tabId = tabId; } if (style !== undefined) { body.style = style; } if (richText !== undefined) { body.richText = richText; } const res = await fetch(`${API_BASE}/sheets/${sheetId}/cells`, { method: 'PUT', headers: authHeaders(), body: JSON.stringify(body), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка сохранения ячейки: ${res.status}`); } return res.json(); } export async function apiUpdateSheetName(id: string, name: string): Promise<SheetResponseDto> { const res = await fetch(`${API_BASE}/sheets/${id}`, { method: 'PUT', headers: authHeaders(), body: JSON.stringify({ name }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка обновления таблицы: ${res.status}`); } return res.json(); } export async function apiInsertRow( sheetId: string, tabId: string, rowIndex: number, ): Promise<void> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/rows/insert`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ tabId, rowIndex }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка вставки строки: ${res.status}`); } } export interface PasteRowsCellData { rowOffset: number; colIndex: number; value: string; style?: SheetCellStyleDto | null; richText?: TextRunDto[] | null; } export async function apiPasteRows( sheetId: string, tabId: string, insertAt: number, numRows: number, cells: PasteRowsCellData[], ): Promise<void> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/paste-rows`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ tabId, insertAt, numRows, cells }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка вставки строк: ${res.status}`); } } export async function apiInsertColumn( sheetId: string, tabId: string, colIndex: number, ): Promise<void> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/columns/insert`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ tabId, colIndex }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка вставки столбца: ${res.status}`); } } export async function apiDeleteRow( sheetId: string, tabId: string, rowIndex: number, ): Promise<void> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/rows/delete`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ tabId, rowIndex }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка удаления строки: ${res.status}`); } } export async function apiDeleteColumn( sheetId: string, tabId: string, colIndex: number, ): Promise<void> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/columns/delete`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ tabId, colIndex }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка удаления столбца: ${res.status}`); } } export async function apiUpdateSheetLayout( id: string, name: string, layout: SheetLayoutDto, ): Promise<SheetResponseDto> { const res = await fetch(`${API_BASE}/sheets/${id}`, { method: 'PUT', headers: authHeaders(), body: JSON.stringify({ name, layout }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка обновления layout таблицы: ${res.status}`); } return res.json(); } export interface UserSearchResult { id: string; username: string; avatarColor?: string; } export async function apiSearchUsers(query: string): Promise<UserSearchResult[]> { const params = new URLSearchParams({ query }); const res = await fetch(`${API_BASE}/users/search?${params}`, { headers: authHeaders() }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); return []; } return res.json(); } export async function apiSetSheetPublic(id: string, publicSheet: boolean): Promise<SheetResponseDto> { const res = await fetch(`${API_BASE}/sheets/${id}/public`, { method: 'PATCH', headers: authHeaders(), body: JSON.stringify({ publicSheet }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); const body = await res.json().catch(() => null); throw new Error(body?.detail || `Ошибка изменения доступа: ${res.status}`); } return res.json(); } export async function apiGetPermissions(sheetId: string): Promise<SheetPermissionDto[]> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/permissions`, { headers: authHeaders() }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка загрузки разрешений: ${res.status}`); } return res.json(); } export async function apiGrantPermission( sheetId: string, username: string, permission: 'READ' | 'EDIT' | 'OWNER', ): Promise<SheetPermissionDto> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/permissions`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, permission }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); const body = await res.json().catch(() => null); throw new Error(body?.detail || `Ошибка выдачи доступа: ${res.status}`); } return res.json(); } export async function apiSendSelection( sheetId: string, tabId: string, selectedCell: { row: number; col: number }, selectedRange: { start: { row: number; col: number }; end: { row: number; col: number } }, ): Promise<void> { const range = selectedRange; const res = await fetch(`${API_BASE}/sheets/${sheetId}/selection`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ tabId, selectedRow: selectedCell.row, selectedCol: selectedCell.col, rangeStartRow: range.start.row, rangeStartCol: range.start.col, rangeEndRow: range.end.row, rangeEndCol: range.end.col, }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); return; // non-critical, ignore errors } // 204 No Content: consume body to release connection (avoids hang in some environments) if (res.status === 204) { await res.text(); } } export async function apiRevokePermission(sheetId: string, targetUserId: string): Promise<void> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/permissions/${targetUserId}`, { method: 'DELETE', headers: authHeaders(), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка отзыва доступа: ${res.status}`); } } // =================== Comments =================== export interface CommentDto { id: string; tabId: string | null; rowIndex: number; colIndex: number; authorId: string; authorUsername: string; authorAvatarColor?: string; text: string; replies: CommentDto[]; createdAt: string; updatedAt: string; } export async function apiGetComments( sheetId: string, tabId: string, rowIndex: number, colIndex: number, ): Promise<CommentDto[]> { const params = new URLSearchParams({ tabId, rowIndex: String(rowIndex), colIndex: String(colIndex) }); const res = await fetch(`${API_BASE}/sheets/${sheetId}/comments?${params}`, { headers: authHeaders() }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка загрузки комментариев: ${res.status}`); } return res.json(); } export async function apiGetCellsWithComments(sheetId: string): Promise<string[]> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/comments/cells`, { headers: authHeaders() }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); return []; } return res.json(); } export async function apiCreateComment( sheetId: string, tabId: string, rowIndex: number, colIndex: number, text: string, parentId?: string, ): Promise<CommentDto> { const body: Record<string, unknown> = { tabId, rowIndex, colIndex, text }; if (parentId) body.parentId = parentId; const res = await fetch(`${API_BASE}/sheets/${sheetId}/comments`, { method: 'POST', headers: authHeaders(), body: JSON.stringify(body), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); const b = await res.json().catch(() => null); throw new Error(b?.detail || `Ошибка создания комментария: ${res.status}`); } return res.json(); } export async function apiUpdateComment( sheetId: string, commentId: string, text: string, ): Promise<CommentDto> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/comments/${commentId}`, { method: 'PUT', headers: authHeaders(), body: JSON.stringify({ text }), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); const b = await res.json().catch(() => null); throw new Error(b?.detail || `Ошибка обновления комментария: ${res.status}`); } return res.json(); } export async function apiDeleteComment(sheetId: string, commentId: string): Promise<void> { const res = await fetch(`${API_BASE}/sheets/${sheetId}/comments/${commentId}`, { method: 'DELETE', headers: authHeaders(), }); if (!res.ok) { if (res.status === 401) throw new Error('UNAUTHORIZED'); throw new Error(`Ошибка удаления комментария: ${res.status}`); } }