/* API layer — auth, fetch, hooks, helpers */ const API_BASE = '/api/v1'; // ── Token cache ─────────────────────────────────────────────── let _token = null; let _tokenExp = 0; async function _ensureToken() { if (_token && Date.now() / 1000 < _tokenExp - 120) return _token; const initData = window.Telegram?.WebApp?.initData; if (!initData) throw new Error('Telegram initData недоступен'); const resp = await fetch(`${API_BASE}/auth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ init_data: initData }), }); if (!resp.ok) { const err = new Error(`Auth failed: ${resp.status}`); err.status = resp.status; throw err; } const d = await resp.json(); _token = d.token; _tokenExp = d.expires_at; return _token; } async function apiFetch(path, opts = {}) { const token = await _ensureToken(); const resp = await fetch(`${API_BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, ...(opts.headers || {}), }, }); if (!resp.ok) { let body; try { body = await resp.json(); } catch { body = { error: resp.statusText }; } const err = new Error(body.error || body.detail || 'api_error'); err.status = resp.status; err.body = body; throw err; } if (resp.status === 204) return null; return resp.json(); } // Multipart form upload (for file inject) async function apiUpload(path, formData) { const token = await _ensureToken(); const resp = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, }); if (!resp.ok) { let body; try { body = await resp.json(); } catch { body = { error: resp.statusText }; } throw Object.assign(new Error(body.error || 'upload_error'), { status: resp.status }); } return resp.json(); } // ── API calls ───────────────────────────────────────────────── const API = { status: () => apiFetch('/status'), inbox: (project) => apiFetch(`/inbox${project ? '?project=' + encodeURIComponent(project) : ''}`), notification: (id) => apiFetch(`/inbox/${id}`), replyNotif: (id, response) => apiFetch(`/inbox/${id}/reply`, { method: 'POST', body: JSON.stringify({ response }) }), projects: () => apiFetch('/projects'), project: (name) => apiFetch(`/projects/${name}`), pauseProject: (name) => apiFetch(`/projects/${name}/pause`, { method: 'POST' }), resumeProject: (name) => apiFetch(`/projects/${name}/resume`, { method: 'POST' }), approveArc: (name, arcId) => apiFetch(`/projects/${name}/arcs/${arcId}/approve`, { method: 'POST' }), rejectArc: (name, arcId, reason) => apiFetch(`/projects/${name}/arcs/${arcId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), chatStart: (name) => apiFetch(`/projects/${name}/chat/start`, { method: 'POST' }), chatStatus: (name) => apiFetch(`/projects/${name}/chat/status`), chatMessage: (name, text) => apiFetch(`/projects/${name}/chat/message`, { method: 'POST', body: JSON.stringify({ text }) }), chatFinalize: (name) => apiFetch(`/projects/${name}/chat/finalize`, { method: 'POST' }), chatRelease: (name) => apiFetch(`/projects/${name}/chat`, { method: 'DELETE' }), settingsMode: () => apiFetch('/settings/mode'), setSettingsMode: (mode) => apiFetch('/settings/mode', { method: 'POST', body: JSON.stringify({ mode }) }), injectText: (name, text) => apiFetch(`/projects/${name}/inject/instruction`, { method: 'POST', body: JSON.stringify({ text }) }), injectFile: (name, formData) => apiUpload(`/projects/${name}/inject/file`, formData), }; // ── Helpers ─────────────────────────────────────────────────── function relTime(ts) { if (!ts) return ''; const diff = Math.floor(Date.now() / 1000 - ts); if (diff < 60) return `${diff}s`; if (diff < 3600) return `${Math.floor(diff / 60)}m`; if (diff < 86400) return `${Math.floor(diff / 3600)}h`; return `${Math.floor(diff / 86400)}d`; } const _NOTIF_ICON_MAP = { phase_plan_review: { kind: 'phase', label: 'PR' }, consilium_iteration_review: { kind: 'cons', label: 'C2' }, todo_escalation: { kind: 'todo', label: 'T!' }, todo_unsure: { kind: 'todo', label: 'T?' }, ultimate_replacement_confirm: { kind: 'system', label: 'U!' }, subproject_merge_approval: { kind: 'merge', label: 'SP' }, arc_closure: { kind: 'arc', label: 'AC' }, linter_degraded: { kind: 'system', label: 'M2' }, }; function notifIcon(type) { return _NOTIF_ICON_MAP[type] || { kind: 'system', label: '?' }; } // ── Loading / error primitives ──────────────────────────────── function LoadingSpinner() { return (
); } function ErrorRetry({ message, onRetry }) { return (
{message || 'Ошибка загрузки'}
{onRetry && }
); } // ── React data hook ─────────────────────────────────────────── function useApi(fetcher, deps) { const [state, setState] = React.useState({ data: null, loading: true, error: null }); const reload = React.useCallback(() => { setState(s => ({ ...s, loading: true, error: null })); fetcher() .then(data => setState({ data, loading: false, error: null })) .catch(e => setState({ data: null, loading: false, error: e.message || 'Ошибка' })); }, deps); React.useEffect(() => { reload(); }, [reload]); return { ...state, reload }; } // CSS for spinner if (!document.getElementById('api-spinner-style')) { const s = document.createElement('style'); s.id = 'api-spinner-style'; s.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'; document.head.appendChild(s); } Object.assign(window, { API, apiFetch, apiUpload, useApi, relTime, notifIcon, LoadingSpinner, ErrorRetry, ensureToken: _ensureToken });