/** * musehub.ts — shared utilities for all MuseHub web pages. * * Sections: * 1. API fetch helper * 2. Formatting helpers (dates, SHA, durations) * 3. Commit message parser (liner-notes display helpers) * 4. HTMX integration hooks */ /* ═══════════════════════════════════════════════════════════════ * 1. API fetch helper * ═══════════════════════════════════════════════════════════════ */ const API = '/api'; export async function apiFetch(path: string, opts: RequestInit = {}): Promise { const res = await fetch(API + path, { ...opts, headers: { ...((opts.headers as Record) ?? {}) }, }); if (res.status === 401 || res.status === 403) { throw new Error('auth: ' + res.status); } if (!res.ok) { const body = await res.text(); throw new Error(res.status + ': ' + body); } return res.json() as unknown; } /* ═══════════════════════════════════════════════════════════════ * 2. Formatting helpers * ═══════════════════════════════════════════════════════════════ */ export function fmtDate(iso: string | null | undefined): string { if (!iso) return '--'; const d = new Date(iso); return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); } export function fmtRelative(iso: string | null | undefined): string { if (!iso) return '--'; const diff = (Date.now() - new Date(iso).getTime()) / 1000; if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; if (diff < 604800) return Math.floor(diff / 86400) + 'd ago'; return fmtDate(iso); } export function shortSha(sha: string | null | undefined): string { return sha ? sha.substring(0, 8) : '--'; } export function fmtDuration(seconds: number | null | undefined): string { if (!seconds || isNaN(seconds)) return '--'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } export function escHtml(s: unknown): string { if (!s) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /* ═══════════════════════════════════════════════════════════════ * 3. Commit message parser * ═══════════════════════════════════════════════════════════════ */ interface CommitType { label: string; color: string } const _COMMIT_TYPES: Record = { feat: { label: 'feat', color: 'var(--color-success)' }, fix: { label: 'fix', color: 'var(--color-danger)' }, refactor: { label: 'refactor', color: 'var(--color-accent)' }, style: { label: 'style', color: 'var(--color-purple)' }, docs: { label: 'docs', color: 'var(--text-muted)' }, chore: { label: 'chore', color: 'var(--color-neutral)' }, init: { label: 'init', color: 'var(--color-warning)' }, perf: { label: 'perf', color: 'var(--color-orange)' }, }; interface ParsedCommit { type: string | null; scope: string | null; subject: string } export function parseCommitMessage(msg: string | null | undefined): ParsedCommit { if (!msg) return { type: null, scope: null, subject: msg ?? '' }; const m = msg.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.*)/s); if (!m) return { type: null, scope: null, subject: msg }; return { type: m[1].toLowerCase(), scope: m[2] ?? null, subject: m[3] }; } export function commitTypeBadge(type: string | null | undefined): string { if (!type) return ''; const t = _COMMIT_TYPES[type] ?? { label: type, color: 'var(--text-muted)' }; return `${escHtml(t.label)}`; } export function commitScopeBadge(scope: string | null | undefined): string { if (!scope) return ''; return `${escHtml(scope)}`; } export function parseCommitMeta(message: string): Record { const meta: Record = {}; const patterns = [ /section:([\w-]+)/i, /track:([\w-]+)/i, /key:([\w#b]+\s*(?:major|minor|maj|min)?)/i, /tempo:(\d+)/i, /bpm:(\d+)/i, ]; const keys = ['section', 'track', 'key', 'tempo', 'bpm']; patterns.forEach((re, i) => { const m = message.match(re); if (m) meta[keys[i]] = m[1]; }); return meta; } /* ═══════════════════════════════════════════════════════════════ * 4. HTMX integration hooks * ═══════════════════════════════════════════════════════════════ */ /* ═══════════════════════════════════════════════════════════════ * Global surface — attach exports to window for inline handlers * ═══════════════════════════════════════════════════════════════ */ declare global { interface Window { apiFetch: (path: string, opts?: RequestInit) => Promise; fmtDate: (iso: string | null | undefined) => string; fmtRelative: (iso: string | null | undefined) => string; shortSha: (sha: string | null | undefined) => string; fmtDuration: (seconds: number | null | undefined) => string; escHtml: (s: unknown) => string; parseCommitMessage: (msg: string | null | undefined) => ParsedCommit; commitTypeBadge: (type: string | null | undefined) => string; commitScopeBadge: (scope: string | null | undefined) => string; parseCommitMeta: (message: string) => Record; switchTab?: (tab: string, filter?: string, page?: number) => void; renderFromObjectId?: (repoId: string, objectId: string, container: HTMLElement | null) => void; renderFromUrl?: (url: string, container: HTMLElement | null) => void; initRepoNav?: (repoId: string) => void; authHeaders?: () => Record; // Alpine.js CSP build global — available after alpine:init fires Alpine?: { data(name: string, factory: () => object): void }; // WaveSurfer global (loaded from CDN) WaveSurfer?: { create: (opts: Record) => unknown }; } } /* ═══════════════════════════════════════════════════════════════ * 7. Global page initialisation (replaces base.html inline script) * ═══════════════════════════════════════════════════════════════ */ function initPageGlobals(): void { // Reveal the page (FOUC prevention: html starts at opacity:0, css-ready triggers fade-in). // This replaces the inline