gabriel / musehub public
musehub.ts typescript
231 lines 10.6 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 /**
2 * musehub.ts — shared utilities for all MuseHub web pages.
3 *
4 * Sections:
5 * 1. API fetch helper
6 * 2. Formatting helpers (dates, SHA, durations)
7 * 3. Commit message parser (liner-notes display helpers)
8 * 4. HTMX integration hooks
9 */
10
11 /* ═══════════════════════════════════════════════════════════════
12 * 1. API fetch helper
13 * ═══════════════════════════════════════════════════════════════ */
14
15 const API = '/api';
16
17 export async function apiFetch(path: string, opts: RequestInit = {}): Promise<unknown> {
18 const res = await fetch(API + path, {
19 ...opts,
20 headers: { ...((opts.headers as Record<string, string>) ?? {}) },
21 });
22 if (res.status === 401 || res.status === 403) {
23 throw new Error('auth: ' + res.status);
24 }
25 if (!res.ok) {
26 const body = await res.text();
27 throw new Error(res.status + ': ' + body);
28 }
29 return res.json() as unknown;
30 }
31
32 /* ═══════════════════════════════════════════════════════════════
33 * 2. Formatting helpers
34 * ═══════════════════════════════════════════════════════════════ */
35
36 export function fmtDate(iso: string | null | undefined): string {
37 if (!iso) return '--';
38 const d = new Date(iso);
39 return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
40 }
41
42 export function fmtRelative(iso: string | null | undefined): string {
43 if (!iso) return '--';
44 const diff = (Date.now() - new Date(iso).getTime()) / 1000;
45 if (diff < 60) return 'just now';
46 if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
47 if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
48 if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
49 return fmtDate(iso);
50 }
51
52 export function shortSha(sha: string | null | undefined): string {
53 return sha ? sha.substring(0, 8) : '--';
54 }
55
56 export function fmtDuration(seconds: number | null | undefined): string {
57 if (!seconds || isNaN(seconds)) return '--';
58 const h = Math.floor(seconds / 3600);
59 const m = Math.floor((seconds % 3600) / 60);
60 const s = Math.floor(seconds % 60);
61 if (h > 0) return `${h}h ${m}m`;
62 if (m > 0) return `${m}m ${s}s`;
63 return `${s}s`;
64 }
65
66 export function escHtml(s: unknown): string {
67 if (!s) return '';
68 return String(s)
69 .replace(/&/g, '&amp;')
70 .replace(/</g, '&lt;')
71 .replace(/>/g, '&gt;')
72 .replace(/"/g, '&quot;');
73 }
74
75 /* ═══════════════════════════════════════════════════════════════
76 * 3. Commit message parser
77 * ═══════════════════════════════════════════════════════════════ */
78
79 interface CommitType { label: string; color: string }
80
81 const _COMMIT_TYPES: Record<string, CommitType> = {
82 feat: { label: 'feat', color: 'var(--color-success)' },
83 fix: { label: 'fix', color: 'var(--color-danger)' },
84 refactor: { label: 'refactor', color: 'var(--color-accent)' },
85 style: { label: 'style', color: 'var(--color-purple)' },
86 docs: { label: 'docs', color: 'var(--text-muted)' },
87 chore: { label: 'chore', color: 'var(--color-neutral)' },
88 init: { label: 'init', color: 'var(--color-warning)' },
89 perf: { label: 'perf', color: 'var(--color-orange)' },
90 };
91
92 interface ParsedCommit { type: string | null; scope: string | null; subject: string }
93
94 export function parseCommitMessage(msg: string | null | undefined): ParsedCommit {
95 if (!msg) return { type: null, scope: null, subject: msg ?? '' };
96 const m = msg.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.*)/s);
97 if (!m) return { type: null, scope: null, subject: msg };
98 return { type: m[1].toLowerCase(), scope: m[2] ?? null, subject: m[3] };
99 }
100
101 export function commitTypeBadge(type: string | null | undefined): string {
102 if (!type) return '';
103 const t = _COMMIT_TYPES[type] ?? { label: type, color: 'var(--text-muted)' };
104 return `<span class="badge" style="background:${t.color}20;color:${t.color};border:1px solid ${t.color}40">${escHtml(t.label)}</span>`;
105 }
106
107 export function commitScopeBadge(scope: string | null | undefined): string {
108 if (!scope) return '';
109 return `<span class="badge" style="background:var(--bg-overlay);color:var(--color-purple);border:1px solid var(--color-purple-bg)">${escHtml(scope)}</span>`;
110 }
111
112 export function parseCommitMeta(message: string): Record<string, string> {
113 const meta: Record<string, string> = {};
114 const patterns = [
115 /section:([\w-]+)/i,
116 /track:([\w-]+)/i,
117 /key:([\w#b]+\s*(?:major|minor|maj|min)?)/i,
118 /tempo:(\d+)/i,
119 /bpm:(\d+)/i,
120 ];
121 const keys = ['section', 'track', 'key', 'tempo', 'bpm'];
122 patterns.forEach((re, i) => {
123 const m = message.match(re);
124 if (m) meta[keys[i]] = m[1];
125 });
126 return meta;
127 }
128
129 /* ═══════════════════════════════════════════════════════════════
130 * 4. HTMX integration hooks
131 * ═══════════════════════════════════════════════════════════════ */
132
133 /* ═══════════════════════════════════════════════════════════════
134 * Global surface — attach exports to window for inline handlers
135 * ═══════════════════════════════════════════════════════════════ */
136
137 declare global {
138 interface Window {
139 apiFetch: (path: string, opts?: RequestInit) => Promise<unknown>;
140 fmtDate: (iso: string | null | undefined) => string;
141 fmtRelative: (iso: string | null | undefined) => string;
142 shortSha: (sha: string | null | undefined) => string;
143 fmtDuration: (seconds: number | null | undefined) => string;
144 escHtml: (s: unknown) => string;
145 parseCommitMessage: (msg: string | null | undefined) => ParsedCommit;
146 commitTypeBadge: (type: string | null | undefined) => string;
147 commitScopeBadge: (scope: string | null | undefined) => string;
148 parseCommitMeta: (message: string) => Record<string, string>;
149 switchTab?: (tab: string, filter?: string, page?: number) => void;
150 renderFromObjectId?: (repoId: string, objectId: string, container: HTMLElement | null) => void;
151 renderFromUrl?: (url: string, container: HTMLElement | null) => void;
152 initRepoNav?: (repoId: string) => void;
153 authHeaders?: () => Record<string, string>;
154 // Alpine.js CSP build global — available after alpine:init fires
155 Alpine?: { data(name: string, factory: () => object): void };
156 // WaveSurfer global (loaded from CDN)
157 WaveSurfer?: { create: (opts: Record<string, unknown>) => unknown };
158 }
159 }
160
161 /* ═══════════════════════════════════════════════════════════════
162 * 7. Global page initialisation (replaces base.html inline script)
163 * ═══════════════════════════════════════════════════════════════ */
164
165 function initPageGlobals(): void {
166 // Reveal the page (FOUC prevention: html starts at opacity:0, css-ready triggers fade-in).
167 // This replaces the inline <script> that previously added css-ready on initial load.
168 document.documentElement.classList.add('css-ready');
169
170 // Dispatch to the active page module via the #page-data JSON element
171 const pageDataEl = document.getElementById('page-data');
172 if (pageDataEl) {
173 try {
174 const pageData = JSON.parse(pageDataEl.textContent ?? '{}') as Record<string, unknown>;
175 dispatchPageModule(pageData);
176 } catch (_) { /* malformed JSON — ignore */ }
177 }
178 }
179
180 function dispatchPageModule(data: Record<string, unknown>): void {
181 const page = data['page'] as string | undefined;
182 if (!page) return;
183 // Page modules register themselves on window.MusePages
184 const pages = (window as unknown as { MusePages?: Record<string, (d: Record<string, unknown>) => void> }).MusePages;
185 if (pages && typeof pages[page] === 'function') {
186 pages[page](data);
187 }
188 }
189
190 // Prevent click-bubbling on symbol deep-links inside clickable rows.
191 // Replaces onclick="event.stopPropagation()" in intel page templates.
192 document.addEventListener('click', (e) => {
193 if ((e.target as Element).closest?.('.sym-deep-link')) e.stopPropagation();
194 }, true);
195
196 // Run on initial hard load
197 document.addEventListener('DOMContentLoaded', initPageGlobals);
198
199 // ── Guard: never let HTMX boost intercept /raw/ URLs ──────────────────────────
200 // Raw endpoints return text/plain — if HTMX boosts them it dumps plain text
201 // into the DOM and breaks the history stack. We cancel the request and let
202 // the browser navigate normally (opens in same tab like a plain <a> click).
203 document.addEventListener('htmx:beforeRequest', (evt: Event) => {
204 const detail = (evt as CustomEvent).detail as { pathInfo?: { requestPath?: string }; xhr?: XMLHttpRequest };
205 const path: string = detail?.pathInfo?.requestPath ?? '';
206 if (path.includes('/raw/')) {
207 evt.preventDefault();
208 window.location.href = path;
209 }
210 });
211
212 // ── HTMX boost navigation: re-init page globals, no opacity manipulation ──
213 // FOUC is only a risk on the very first page load (handled by the inline
214 // CSS + <script> in base.html). HTMX swaps reuse the already-loaded CSS,
215 // so removing css-ready here would only cause a white flash (the browser
216 // backdrop shines through the opacity:0 html element).
217 document.addEventListener('htmx:afterSettle', () => {
218 document.documentElement.classList.add('css-ready');
219 initPageGlobals();
220 });
221
222 window.apiFetch = apiFetch;
223 window.fmtDate = fmtDate;
224 window.fmtRelative = fmtRelative;
225 window.shortSha = shortSha;
226 window.fmtDuration = fmtDuration;
227 window.escHtml = escHtml;
228 window.parseCommitMessage = parseCommitMessage;
229 window.commitTypeBadge = commitTypeBadge;
230 window.commitScopeBadge = commitScopeBadge;
231 window.parseCommitMeta = parseCommitMeta;
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago