synops/frontend/src/lib/api.ts
vegard d8c0cceb89 Kanban-visning: board med drag-and-drop statusendring (oppgave 9.1)
Implementerer kanban som noder+edges uten separate tabeller:
- Board = collection-node med metadata.board og metadata.columns
- Kort = content-noder med belongs_to-edge til board
- Status via status-edge (kort→board) med metadata.value
- Posisjon via belongs_to-edge metadata.position

Backend:
- POST /intentions/update_edge — oppdater edge-type/metadata
- GET /query/board?board_id= — hent kort med status og posisjon

Frontend:
- /board/[id] route med kolonner, drag-and-drop, kortoppretting
- Sanntid via SpacetimeDB edge-subscriptions
- Board-oppretting og navigasjon fra mottak-siden

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:13:22 +00:00

336 lines
8.7 KiB
TypeScript

/**
* Client for maskinrommet intentions API.
* Uses the Vite dev proxy (/api → api.sidelinja.org) in development.
* In production, set VITE_API_URL to the maskinrommet URL.
*/
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api';
export interface CreateNodeRequest {
node_kind?: string;
title?: string;
content?: string;
visibility?: string;
metadata?: Record<string, unknown>;
/** Kontekst-node (kommunikasjonsnode). Gir automatisk belongs_to-edge. */
context_id?: string;
}
export interface CreateNodeResponse {
node_id: string;
/** Edge-ID for automatisk belongs_to-edge (kun ved context_id). */
belongs_to_edge_id?: string;
}
export interface CreateEdgeRequest {
source_id: string;
target_id: string;
edge_type: string;
metadata?: Record<string, unknown>;
system?: boolean;
}
export interface CreateEdgeResponse {
edge_id: string;
}
async function post<T>(accessToken: string, path: string, data: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
},
body: JSON.stringify(data)
});
if (!res.ok) {
const body = await res.text();
throw new Error(`${path} failed (${res.status}): ${body}`);
}
return res.json();
}
export function createNode(
accessToken: string,
data: CreateNodeRequest
): Promise<CreateNodeResponse> {
return post(accessToken, '/intentions/create_node', data);
}
export function createEdge(
accessToken: string,
data: CreateEdgeRequest
): Promise<CreateEdgeResponse> {
return post(accessToken, '/intentions/create_edge', data);
}
// =============================================================================
// Edge-oppdatering
// =============================================================================
export interface UpdateEdgeRequest {
edge_id: string;
edge_type?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateEdgeResponse {
edge_id: string;
}
export function updateEdge(
accessToken: string,
data: UpdateEdgeRequest
): Promise<UpdateEdgeResponse> {
return post(accessToken, '/intentions/update_edge', data);
}
// =============================================================================
// Board / Kanban
// =============================================================================
export interface BoardCard {
node_id: string;
title: string | null;
content: string | null;
node_kind: string;
metadata: Record<string, unknown>;
created_at: string;
created_by: string | null;
status: string | null;
position: number;
belongs_to_edge_id: string;
status_edge_id: string | null;
}
export interface BoardResponse {
board_id: string;
board_title: string | null;
columns: string[];
cards: BoardCard[];
}
export async function fetchBoard(accessToken: string, boardId: string): Promise<BoardResponse> {
const res = await fetch(`${BASE_URL}/query/board?board_id=${encodeURIComponent(boardId)}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`board failed (${res.status}): ${body}`);
}
return res.json();
}
// =============================================================================
// Kommunikasjon
// =============================================================================
export interface CreateCommunicationRequest {
title?: string;
participants?: string[];
visibility?: string;
metadata?: Record<string, unknown>;
}
export interface CreateCommunicationResponse {
node_id: string;
edge_ids: string[];
}
export function createCommunication(
accessToken: string,
data: CreateCommunicationRequest
): Promise<CreateCommunicationResponse> {
return post(accessToken, '/intentions/create_communication', data);
}
export interface UploadMediaRequest {
file: File;
source_id?: string;
visibility?: string;
title?: string;
}
export interface UploadMediaResponse {
media_node_id: string;
cas_hash: string;
size_bytes: number;
already_existed: boolean;
has_media_edge_id?: string;
}
export async function uploadMedia(
accessToken: string,
data: UploadMediaRequest
): Promise<UploadMediaResponse> {
const form = new FormData();
form.append('file', data.file);
if (data.source_id) form.append('source_id', data.source_id);
if (data.visibility) form.append('visibility', data.visibility);
if (data.title) form.append('title', data.title);
const res = await fetch(`${BASE_URL}/intentions/upload_media`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: form
});
if (!res.ok) {
const body = await res.text();
throw new Error(`upload_media failed (${res.status}): ${body}`);
}
return res.json();
}
/** Build the CAS URL for a given hash. */
export function casUrl(hash: string): string {
return `${BASE_URL}/cas/${hash}`;
}
// =============================================================================
// Transkripsjons-segmenter
// =============================================================================
export interface Segment {
id: number;
seq: number;
start_ms: number;
end_ms: number;
content: string;
edited: boolean;
}
export interface SegmentsResponse {
segments: Segment[];
transcribed_at: string | null;
}
/** Hent transkripsjons-segmenter for en media-node. */
export async function fetchSegments(
accessToken: string,
nodeId: string
): Promise<SegmentsResponse> {
const res = await fetch(`${BASE_URL}/query/segments?node_id=${encodeURIComponent(nodeId)}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`segments failed (${res.status}): ${body}`);
}
return res.json();
}
/** Last ned SRT-fil for en media-node. Trigger filnedlasting i nettleseren. */
export async function downloadSrt(accessToken: string, nodeId: string): Promise<void> {
const res = await fetch(
`${BASE_URL}/query/segments/srt?node_id=${encodeURIComponent(nodeId)}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) {
const body = await res.text();
throw new Error(`SRT-eksport feilet (${res.status}): ${body}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'transcription.srt';
a.click();
URL.revokeObjectURL(url);
}
/** Oppdater teksten i et transkripsjons-segment. */
export function updateSegment(
accessToken: string,
segmentId: number,
content: string
): Promise<{ segment_id: number; edited: boolean }> {
return post(accessToken, '/intentions/update_segment', {
segment_id: segmentId,
content
});
}
// =============================================================================
// Re-transkripsjon
// =============================================================================
export interface TranscriptionVersion {
transcribed_at: string;
segment_count: number;
edited_count: number;
}
export interface TranscriptionVersionsResponse {
versions: TranscriptionVersion[];
}
/** Hent alle transkripsjonsversjoner for en node. */
export async function fetchTranscriptionVersions(
accessToken: string,
nodeId: string
): Promise<TranscriptionVersionsResponse> {
const res = await fetch(
`${BASE_URL}/query/transcription_versions?node_id=${encodeURIComponent(nodeId)}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) {
const body = await res.text();
throw new Error(`transcription_versions failed (${res.status}): ${body}`);
}
return res.json();
}
/** Hent segmenter for en spesifikk transkripsjonsversjon. */
export async function fetchSegmentsVersion(
accessToken: string,
nodeId: string,
transcribedAt: string
): Promise<SegmentsResponse> {
const params = new URLSearchParams({
node_id: nodeId,
transcribed_at: transcribedAt
});
const res = await fetch(`${BASE_URL}/query/segments_version?${params}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`segments_version failed (${res.status}): ${body}`);
}
return res.json();
}
/** Trigger re-transkripsjon for en media-node. */
export function retranscribe(
accessToken: string,
nodeId: string
): Promise<{ job_id: string }> {
return post(accessToken, '/intentions/retranscribe', { node_id: nodeId });
}
export interface SegmentChoice {
seq: number;
choice: 'new' | 'old';
}
/** Anvend brukerens per-segment-valg etter re-transkripsjon. */
export function resolveRetranscription(
accessToken: string,
nodeId: string,
newVersion: string,
oldVersion: string,
choices: SegmentChoice[]
): Promise<{ resolved: boolean; kept_old: number; kept_new: number }> {
return post(accessToken, '/intentions/resolve_retranscription', {
node_id: nodeId,
new_version: newVersion,
old_version: oldVersion,
choices
});
}