initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

90
frontend/src/lib/api.js Normal file
View file

@ -0,0 +1,90 @@
const RAW_BASE = import.meta.env.VITE_API_BASE ?? '';
const DEFAULT_TIMEOUT = 15000;
/** API error carrying the HTTP status and the server's structured error code. */
export class ApiError extends Error {
constructor(message, status, code) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
async function request(path, { method = 'GET', body, headers = {}, signal } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
try {
const response = await fetch(`${RAW_BASE}${path}`, {
method,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
if (response.status === 204) return null;
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
const code = payload?.error?.code;
const message = payload?.error?.message ?? `${response.status} ${response.statusText}`;
throw new ApiError(message, response.status, code);
}
return payload;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error.name === 'AbortError') {
throw new ApiError('Request timed out or was cancelled', 0, 'timeout');
}
throw new ApiError(error.message || 'Network unreachable', 0, 'network');
} finally {
clearTimeout(timer);
}
}
export const api = {
health: () => request('/api/health'),
roles: () => request('/api/roles'),
listDocs: (query = '') =>
request(`/api/docs${query ? `?q=${encodeURIComponent(query)}` : ''}`),
listDocsDetailed: () => request('/api/docs?counts=1'),
getDoc: (slug) => request(`/api/docs/${encodeURIComponent(slug)}?format=json`),
saveDoc: (slug, content, title) => request(`/api/docs/${encodeURIComponent(slug)}`, {
method: 'POST',
body: { content, title },
}),
deleteDoc: (slug) => request(`/api/docs/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
listComments: (slug) => request(`/api/docs/${encodeURIComponent(slug)}/comments`),
createComment: (slug, comment) =>
request(`/api/docs/${encodeURIComponent(slug)}/comments`, { method: 'POST', body: comment }),
deleteComment: (slug, id) =>
request(`/api/docs/${encodeURIComponent(slug)}/comments/${encodeURIComponent(id)}`, {
method: 'DELETE',
}),
};
/** Audience segments mirrored from the Go engine; badges stay authoritative server-side. */
export const ROLES = [
{ role: 'user', badge: '🟢', label: 'User', hex: '#22c55e', description: 'Day-to-day workflow guidance' },
{ role: 'operator', badge: '🔵', label: 'Operator', hex: '#3b82f6', description: 'Deployment, health and rollback' },
{ role: 'developer', badge: '🔴', label: 'Developer', hex: '#ef4444', description: 'Implementation and API contracts' },
];
export function roleMeta(role) {
return ROLES.find((r) => r.role === role) ?? { role, badge: '⚪', label: role, hex: '#94a3b8' };
}