@agentlip/hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts ADDED
@@ -0,0 +1,843 @@
1
+ /**
2
+ * Agentlip Hub UI handler
3
+ *
4
+ * Minimal HTML UI served at /ui/* routes with:
5
+ * - Channel list (/ui)
6
+ * - Topic list (/ui/channels/:channel_id)
7
+ * - Messages view (/ui/topics/:topic_id) with live updates via WS
8
+ *
9
+ * No build step: inline HTML/CSS/JS.
10
+ * Security: all user content escaped via textContent, URLs validated.
11
+ */
12
+
13
+ interface UiContext {
14
+ baseUrl: string;
15
+ authToken: string;
16
+ }
17
+
18
+ /**
19
+ * Handle UI requests.
20
+ * Returns Response if route matches, null if not found.
21
+ */
22
+ export function handleUiRequest(req: Request, ctx: UiContext): Response | null {
23
+ const url = new URL(req.url);
24
+ const path = url.pathname;
25
+
26
+ // Only accept GET requests
27
+ if (req.method !== "GET") {
28
+ return null;
29
+ }
30
+
31
+ // GET /ui → Channels list page
32
+ if (path === "/ui" || path === "/ui/") {
33
+ return renderChannelsListPage(ctx);
34
+ }
35
+
36
+ // GET /ui/channels/:channel_id → Topics list for a channel
37
+ const channelMatch = path.match(/^\/ui\/channels\/([^/]+)$/);
38
+ if (channelMatch) {
39
+ const channelId = channelMatch[1];
40
+ return renderTopicsListPage(ctx, channelId);
41
+ }
42
+
43
+ // GET /ui/topics/:topic_id → Messages view for a topic
44
+ const topicMatch = path.match(/^\/ui\/topics\/([^/]+)$/);
45
+ if (topicMatch) {
46
+ const topicId = topicMatch[1];
47
+ return renderTopicMessagesPage(ctx, topicId);
48
+ }
49
+
50
+ // Route not found
51
+ return null;
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Page Renderers
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ function renderChannelsListPage(ctx: UiContext): Response {
59
+ const html = `<!DOCTYPE html>
60
+ <html lang="en">
61
+ <head>
62
+ <meta charset="UTF-8">
63
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
64
+ <title>Agentlip - Channels</title>
65
+ <style>${getCommonStyles()}</style>
66
+ </head>
67
+ <body>
68
+ <div class="container">
69
+ <header>
70
+ <h1>Channels</h1>
71
+ </header>
72
+ <main>
73
+ <div id="loading">Loading channels...</div>
74
+ <div id="error" style="display:none"></div>
75
+ <ul id="channels-list" style="display:none"></ul>
76
+ </main>
77
+ </div>
78
+
79
+ <script>
80
+ const API_BASE = ${JSON.stringify(ctx.baseUrl)};
81
+ const AUTH_TOKEN = ${JSON.stringify(ctx.authToken)};
82
+
83
+ async function loadChannels() {
84
+ const loading = document.getElementById('loading');
85
+ const error = document.getElementById('error');
86
+ const list = document.getElementById('channels-list');
87
+
88
+ try {
89
+ const res = await fetch(API_BASE + '/api/v1/channels', {
90
+ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN }
91
+ });
92
+
93
+ if (!res.ok) {
94
+ throw new Error('Failed to load channels: ' + res.status);
95
+ }
96
+
97
+ const data = await res.json();
98
+ const channels = data.channels || [];
99
+
100
+ loading.style.display = 'none';
101
+
102
+ if (channels.length === 0) {
103
+ error.textContent = 'No channels found';
104
+ error.style.display = 'block';
105
+ return;
106
+ }
107
+
108
+ // Render channels
109
+ list.innerHTML = '';
110
+ for (const channel of channels) {
111
+ const li = document.createElement('li');
112
+ const link = document.createElement('a');
113
+ link.href = '/ui/channels/' + encodeURIComponent(channel.id);
114
+ link.textContent = channel.name;
115
+
116
+ if (channel.description) {
117
+ const desc = document.createElement('div');
118
+ desc.className = 'description';
119
+ desc.textContent = channel.description;
120
+ li.appendChild(link);
121
+ li.appendChild(desc);
122
+ } else {
123
+ li.appendChild(link);
124
+ }
125
+
126
+ list.appendChild(li);
127
+ }
128
+
129
+ list.style.display = 'block';
130
+ } catch (err) {
131
+ loading.style.display = 'none';
132
+ error.textContent = 'Error: ' + err.message;
133
+ error.style.display = 'block';
134
+ }
135
+ }
136
+
137
+ loadChannels();
138
+ </script>
139
+ </body>
140
+ </html>`;
141
+
142
+ return new Response(html, {
143
+ status: 200,
144
+ headers: { "Content-Type": "text/html; charset=utf-8" },
145
+ });
146
+ }
147
+
148
+ function renderTopicsListPage(ctx: UiContext, channelId: string): Response {
149
+ const html = `<!DOCTYPE html>
150
+ <html lang="en">
151
+ <head>
152
+ <meta charset="UTF-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
154
+ <title>Agentlip - Topics</title>
155
+ <style>${getCommonStyles()}</style>
156
+ </head>
157
+ <body>
158
+ <div class="container">
159
+ <header>
160
+ <nav>
161
+ <a href="/ui">← Channels</a>
162
+ </nav>
163
+ <h1 id="channel-name">Topics</h1>
164
+ </header>
165
+ <main>
166
+ <div id="loading">Loading topics...</div>
167
+ <div id="error" style="display:none"></div>
168
+ <ul id="topics-list" style="display:none"></ul>
169
+ </main>
170
+ </div>
171
+
172
+ <script>
173
+ const API_BASE = ${JSON.stringify(ctx.baseUrl)};
174
+ const AUTH_TOKEN = ${JSON.stringify(ctx.authToken)};
175
+ const CHANNEL_ID = ${JSON.stringify(channelId)};
176
+
177
+ async function loadChannel() {
178
+ try {
179
+ const res = await fetch(API_BASE + '/api/v1/channels', {
180
+ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN }
181
+ });
182
+
183
+ if (res.ok) {
184
+ const data = await res.json();
185
+ const channel = data.channels.find(ch => ch.id === CHANNEL_ID);
186
+ if (channel) {
187
+ document.getElementById('channel-name').textContent = channel.name;
188
+ }
189
+ }
190
+ } catch (err) {
191
+ // Best effort - don't block topics load
192
+ }
193
+ }
194
+
195
+ async function loadTopics() {
196
+ const loading = document.getElementById('loading');
197
+ const error = document.getElementById('error');
198
+ const list = document.getElementById('topics-list');
199
+
200
+ try {
201
+ const res = await fetch(
202
+ API_BASE + '/api/v1/channels/' + encodeURIComponent(CHANNEL_ID) + '/topics',
203
+ { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
204
+ );
205
+
206
+ if (!res.ok) {
207
+ throw new Error('Failed to load topics: ' + res.status);
208
+ }
209
+
210
+ const data = await res.json();
211
+ const topics = data.topics || [];
212
+
213
+ loading.style.display = 'none';
214
+
215
+ if (topics.length === 0) {
216
+ error.textContent = 'No topics found';
217
+ error.style.display = 'block';
218
+ return;
219
+ }
220
+
221
+ // Render topics
222
+ list.innerHTML = '';
223
+ for (const topic of topics) {
224
+ const li = document.createElement('li');
225
+ const link = document.createElement('a');
226
+ link.href = '/ui/topics/' + encodeURIComponent(topic.id);
227
+ link.textContent = topic.title;
228
+
229
+ const meta = document.createElement('div');
230
+ meta.className = 'meta';
231
+ meta.textContent = 'Updated: ' + new Date(topic.updated_at).toLocaleString();
232
+
233
+ li.appendChild(link);
234
+ li.appendChild(meta);
235
+ list.appendChild(li);
236
+ }
237
+
238
+ list.style.display = 'block';
239
+ } catch (err) {
240
+ loading.style.display = 'none';
241
+ error.textContent = 'Error: ' + err.message;
242
+ error.style.display = 'block';
243
+ }
244
+ }
245
+
246
+ loadChannel();
247
+ loadTopics();
248
+ </script>
249
+ </body>
250
+ </html>`;
251
+
252
+ return new Response(html, {
253
+ status: 200,
254
+ headers: { "Content-Type": "text/html; charset=utf-8" },
255
+ });
256
+ }
257
+
258
+ function renderTopicMessagesPage(ctx: UiContext, topicId: string): Response {
259
+ const html = `<!DOCTYPE html>
260
+ <html lang="en">
261
+ <head>
262
+ <meta charset="UTF-8">
263
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
264
+ <title>Agentlip - Messages</title>
265
+ <style>${getCommonStyles()}
266
+ .layout {
267
+ display: flex;
268
+ gap: 20px;
269
+ align-items: flex-start;
270
+ }
271
+ .messages-column {
272
+ flex: 1;
273
+ min-width: 0;
274
+ }
275
+ .attachments-column {
276
+ width: 300px;
277
+ flex-shrink: 0;
278
+ }
279
+ .message {
280
+ border-bottom: 1px solid var(--border-color);
281
+ padding: 12px 0;
282
+ }
283
+ .message:last-child {
284
+ border-bottom: none;
285
+ }
286
+ .message-header {
287
+ display: flex;
288
+ gap: 8px;
289
+ align-items: baseline;
290
+ margin-bottom: 6px;
291
+ font-size: 0.9em;
292
+ }
293
+ .sender {
294
+ font-weight: 600;
295
+ color: var(--primary-color);
296
+ }
297
+ .timestamp {
298
+ color: var(--meta-color);
299
+ font-size: 0.9em;
300
+ }
301
+ .badge {
302
+ font-size: 0.85em;
303
+ padding: 2px 6px;
304
+ border-radius: 3px;
305
+ background: var(--border-color);
306
+ color: var(--meta-color);
307
+ }
308
+ .content {
309
+ margin: 8px 0;
310
+ white-space: pre-wrap;
311
+ word-wrap: break-word;
312
+ }
313
+ .deleted {
314
+ color: var(--meta-color);
315
+ font-style: italic;
316
+ }
317
+ .attachment {
318
+ border: 1px solid var(--border-color);
319
+ border-radius: 6px;
320
+ padding: 8px;
321
+ margin-bottom: 8px;
322
+ }
323
+ .attachment-kind {
324
+ font-size: 0.85em;
325
+ font-weight: 600;
326
+ text-transform: uppercase;
327
+ color: var(--meta-color);
328
+ margin-bottom: 4px;
329
+ }
330
+ .attachment-url {
331
+ word-break: break-all;
332
+ font-size: 0.9em;
333
+ }
334
+ @media (max-width: 768px) {
335
+ .layout {
336
+ flex-direction: column;
337
+ }
338
+ .attachments-column {
339
+ width: 100%;
340
+ }
341
+ }
342
+ </style>
343
+ </head>
344
+ <body>
345
+ <div class="container">
346
+ <header>
347
+ <nav>
348
+ <a href="/ui">← Channels</a>
349
+ <span id="breadcrumb"></span>
350
+ </nav>
351
+ <h1 id="topic-title">Messages</h1>
352
+ </header>
353
+ <main>
354
+ <div id="loading">Loading messages...</div>
355
+ <div id="error" style="display:none"></div>
356
+ <div id="content" class="layout" style="display:none">
357
+ <div class="messages-column">
358
+ <div id="messages-list"></div>
359
+ </div>
360
+ <div class="attachments-column">
361
+ <h2>Attachments</h2>
362
+ <div id="attachments-list"></div>
363
+ </div>
364
+ </div>
365
+ </main>
366
+ </div>
367
+
368
+ <script>
369
+ const API_BASE = ${JSON.stringify(ctx.baseUrl)};
370
+ const AUTH_TOKEN = ${JSON.stringify(ctx.authToken)};
371
+ const TOPIC_ID = ${JSON.stringify(topicId)};
372
+
373
+ const state = {
374
+ messages: new Map(), // message_id -> message object
375
+ attachments: [],
376
+ highestEventId: 0,
377
+ ws: null,
378
+ topicData: null,
379
+ };
380
+
381
+ // ─────────────────────────────────────────────────────────────────────────
382
+ // URL Validation (security)
383
+ // ─────────────────────────────────────────────────────────────────────────
384
+
385
+ function isValidUrl(urlString) {
386
+ try {
387
+ const url = new URL(urlString);
388
+ return url.protocol === 'http:' || url.protocol === 'https:';
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ // ─────────────────────────────────────────────────────────────────────────
395
+ // Rendering
396
+ // ─────────────────────────────────────────────────────────────────────────
397
+
398
+ function renderMessage(msg) {
399
+ const messageEl = document.createElement('div');
400
+ messageEl.className = 'message';
401
+ messageEl.dataset.messageId = msg.id;
402
+
403
+ const header = document.createElement('div');
404
+ header.className = 'message-header';
405
+
406
+ const sender = document.createElement('span');
407
+ sender.className = 'sender';
408
+ sender.textContent = msg.sender;
409
+ header.appendChild(sender);
410
+
411
+ const timestamp = document.createElement('span');
412
+ timestamp.className = 'timestamp';
413
+ timestamp.textContent = new Date(msg.created_at).toLocaleString();
414
+ header.appendChild(timestamp);
415
+
416
+ if (msg.version > 1) {
417
+ const versionBadge = document.createElement('span');
418
+ versionBadge.className = 'badge';
419
+ versionBadge.textContent = 'v' + msg.version;
420
+ header.appendChild(versionBadge);
421
+ }
422
+
423
+ if (msg.edited_at) {
424
+ const editedBadge = document.createElement('span');
425
+ editedBadge.className = 'badge';
426
+ editedBadge.textContent = 'edited';
427
+ header.appendChild(editedBadge);
428
+ }
429
+
430
+ messageEl.appendChild(header);
431
+
432
+ const content = document.createElement('div');
433
+ content.className = 'content';
434
+
435
+ if (msg.deleted_at) {
436
+ content.classList.add('deleted');
437
+ content.textContent = '[deleted by ' + (msg.deleted_by || 'unknown') + ']';
438
+ } else {
439
+ content.textContent = msg.content_raw;
440
+ }
441
+
442
+ messageEl.appendChild(content);
443
+
444
+ return messageEl;
445
+ }
446
+
447
+ function renderMessages() {
448
+ const list = document.getElementById('messages-list');
449
+ list.innerHTML = '';
450
+
451
+ // Sort messages by created_at
452
+ const sorted = Array.from(state.messages.values())
453
+ .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
454
+
455
+ for (const msg of sorted) {
456
+ list.appendChild(renderMessage(msg));
457
+ }
458
+ }
459
+
460
+ function updateMessage(msg) {
461
+ state.messages.set(msg.id, msg);
462
+
463
+ // Find existing message element
464
+ const existing = document.querySelector('[data-message-id="' + msg.id + '"]');
465
+ if (existing) {
466
+ existing.replaceWith(renderMessage(msg));
467
+ } else {
468
+ // New message - re-render all to maintain sort order
469
+ renderMessages();
470
+ }
471
+ }
472
+
473
+ function renderAttachment(att) {
474
+ const attEl = document.createElement('div');
475
+ attEl.className = 'attachment';
476
+ attEl.dataset.attachmentId = att.id;
477
+
478
+ const kind = document.createElement('div');
479
+ kind.className = 'attachment-kind';
480
+ kind.textContent = att.kind;
481
+ attEl.appendChild(kind);
482
+
483
+ if (att.kind === 'url' && att.value_json && att.value_json.url) {
484
+ const url = att.value_json.url;
485
+
486
+ if (isValidUrl(url)) {
487
+ const link = document.createElement('a');
488
+ link.className = 'attachment-url';
489
+ link.href = url;
490
+ link.target = '_blank';
491
+ link.rel = 'noopener noreferrer';
492
+ link.textContent = url;
493
+ attEl.appendChild(link);
494
+ } else {
495
+ const text = document.createElement('div');
496
+ text.className = 'attachment-url';
497
+ text.textContent = url;
498
+ attEl.appendChild(text);
499
+ }
500
+ } else {
501
+ const value = document.createElement('pre');
502
+ value.style.fontSize = '0.85em';
503
+ value.style.overflow = 'auto';
504
+ value.textContent = JSON.stringify(att.value_json, null, 2);
505
+ attEl.appendChild(value);
506
+ }
507
+
508
+ return attEl;
509
+ }
510
+
511
+ function renderAttachments() {
512
+ const list = document.getElementById('attachments-list');
513
+ list.innerHTML = '';
514
+
515
+ if (state.attachments.length === 0) {
516
+ list.textContent = 'No attachments';
517
+ return;
518
+ }
519
+
520
+ for (const att of state.attachments) {
521
+ list.appendChild(renderAttachment(att));
522
+ }
523
+ }
524
+
525
+ function addAttachment(att) {
526
+ // Check if already exists (dedupe)
527
+ const exists = state.attachments.find(a => a.id === att.id);
528
+ if (!exists) {
529
+ state.attachments.push(att);
530
+ renderAttachments();
531
+ }
532
+ }
533
+
534
+ // ─────────────────────────────────────────────────────────────────────────
535
+ // Data Loading
536
+ // ─────────────────────────────────────────────────────────────────────────
537
+
538
+ async function loadTopic() {
539
+ try {
540
+ const res = await fetch(
541
+ API_BASE + '/api/v1/channels',
542
+ { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
543
+ );
544
+
545
+ if (res.ok) {
546
+ const channelsData = await res.json();
547
+ // Find the channel that contains this topic
548
+ for (const channel of channelsData.channels || []) {
549
+ const topicsRes = await fetch(
550
+ API_BASE + '/api/v1/channels/' + encodeURIComponent(channel.id) + '/topics',
551
+ { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
552
+ );
553
+
554
+ if (topicsRes.ok) {
555
+ const topicsData = await topicsRes.json();
556
+ const topic = topicsData.topics.find(t => t.id === TOPIC_ID);
557
+
558
+ if (topic) {
559
+ state.topicData = { ...topic, channel };
560
+ document.getElementById('topic-title').textContent = topic.title;
561
+ document.getElementById('breadcrumb').textContent = ' / ' + channel.name;
562
+ return;
563
+ }
564
+ }
565
+ }
566
+ }
567
+ } catch (err) {
568
+ // Best effort - don't block messages load
569
+ }
570
+ }
571
+
572
+ async function loadMessages() {
573
+ const loading = document.getElementById('loading');
574
+ const error = document.getElementById('error');
575
+ const content = document.getElementById('content');
576
+
577
+ try {
578
+ const res = await fetch(
579
+ API_BASE + '/api/v1/messages?topic_id=' + encodeURIComponent(TOPIC_ID) + '&limit=50',
580
+ { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
581
+ );
582
+
583
+ if (!res.ok) {
584
+ throw new Error('Failed to load messages: ' + res.status);
585
+ }
586
+
587
+ const data = await res.json();
588
+ const messages = data.messages || [];
589
+
590
+ // Store messages
591
+ for (const msg of messages) {
592
+ state.messages.set(msg.id, msg);
593
+ }
594
+
595
+ loading.style.display = 'none';
596
+ content.style.display = 'flex';
597
+
598
+ renderMessages();
599
+ } catch (err) {
600
+ loading.style.display = 'none';
601
+ error.textContent = 'Error loading messages: ' + err.message;
602
+ error.style.display = 'block';
603
+ }
604
+ }
605
+
606
+ async function loadAttachments() {
607
+ try {
608
+ const res = await fetch(
609
+ API_BASE + '/api/v1/topics/' + encodeURIComponent(TOPIC_ID) + '/attachments',
610
+ { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
611
+ );
612
+
613
+ if (res.ok) {
614
+ const data = await res.json();
615
+ state.attachments = data.attachments || [];
616
+ renderAttachments();
617
+ }
618
+ } catch (err) {
619
+ // Best effort - don't block page load
620
+ }
621
+ }
622
+
623
+ // ─────────────────────────────────────────────────────────────────────────
624
+ // WebSocket Live Updates
625
+ // ─────────────────────────────────────────────────────────────────────────
626
+
627
+ function connectWebSocket() {
628
+ // Determine highest event_id from messages
629
+ let highestEventId = 0;
630
+ for (const msg of state.messages.values()) {
631
+ // We don't have event_id on message objects, so we'll use 0
632
+ // The WS will send all new events after connection
633
+ }
634
+ state.highestEventId = highestEventId;
635
+
636
+ const wsUrl = new URL(API_BASE);
637
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
638
+ wsUrl.pathname = '/ws';
639
+ wsUrl.searchParams.set('token', AUTH_TOKEN);
640
+
641
+ const ws = new WebSocket(wsUrl.toString());
642
+ state.ws = ws;
643
+
644
+ ws.onopen = () => {
645
+ // Send hello message
646
+ ws.send(JSON.stringify({
647
+ type: 'hello',
648
+ after_event_id: state.highestEventId,
649
+ subscriptions: {
650
+ topics: [TOPIC_ID]
651
+ }
652
+ }));
653
+ };
654
+
655
+ ws.onmessage = (event) => {
656
+ try {
657
+ const msg = JSON.parse(event.data);
658
+
659
+ if (msg.type === 'hello_ok') {
660
+ state.highestEventId = msg.replay_until;
661
+ return;
662
+ }
663
+
664
+ if (msg.type === 'event') {
665
+ handleEvent(msg);
666
+ state.highestEventId = Math.max(state.highestEventId, msg.event_id);
667
+ }
668
+ } catch (err) {
669
+ console.error('WS message parse error:', err);
670
+ }
671
+ };
672
+
673
+ ws.onerror = (err) => {
674
+ console.error('WebSocket error:', err);
675
+ };
676
+
677
+ ws.onclose = () => {
678
+ // Attempt to reconnect after 5s
679
+ setTimeout(connectWebSocket, 5000);
680
+ };
681
+ }
682
+
683
+ function handleEvent(event) {
684
+ if (event.name === 'message.created' && event.data.message) {
685
+ updateMessage(event.data.message);
686
+ } else if (event.name === 'message.edited' && event.data.message) {
687
+ updateMessage(event.data.message);
688
+ } else if (event.name === 'message.deleted' && event.data.message) {
689
+ updateMessage(event.data.message);
690
+ } else if (event.name === 'topic.attachment_added' && event.data.attachment) {
691
+ addAttachment(event.data.attachment);
692
+ }
693
+ }
694
+
695
+ // ─────────────────────────────────────────────────────────────────────────
696
+ // Initialize
697
+ // ─────────────────────────────────────────────────────────────────────────
698
+
699
+ async function init() {
700
+ await Promise.all([
701
+ loadTopic(),
702
+ loadMessages(),
703
+ loadAttachments(),
704
+ ]);
705
+
706
+ // Start WebSocket connection after initial load
707
+ connectWebSocket();
708
+ }
709
+
710
+ init();
711
+ </script>
712
+ </body>
713
+ </html>`;
714
+
715
+ return new Response(html, {
716
+ status: 200,
717
+ headers: { "Content-Type": "text/html; charset=utf-8" },
718
+ });
719
+ }
720
+
721
+ // ─────────────────────────────────────────────────────────────────────────────
722
+ // Common Styles
723
+ // ─────────────────────────────────────────────────────────────────────────────
724
+
725
+ function getCommonStyles(): string {
726
+ return `
727
+ :root {
728
+ --bg-color: #ffffff;
729
+ --text-color: #1a1a1a;
730
+ --border-color: #e0e0e0;
731
+ --primary-color: #0066cc;
732
+ --meta-color: #666;
733
+ --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
734
+ }
735
+
736
+ @media (prefers-color-scheme: dark) {
737
+ :root {
738
+ --bg-color: #1a1a1a;
739
+ --text-color: #e0e0e0;
740
+ --border-color: #333;
741
+ --primary-color: #4d9fff;
742
+ --meta-color: #999;
743
+ }
744
+ }
745
+
746
+ * {
747
+ margin: 0;
748
+ padding: 0;
749
+ box-sizing: border-box;
750
+ }
751
+
752
+ body {
753
+ font-family: var(--font-family);
754
+ background: var(--bg-color);
755
+ color: var(--text-color);
756
+ line-height: 1.6;
757
+ padding: 20px;
758
+ }
759
+
760
+ .container {
761
+ max-width: 1200px;
762
+ margin: 0 auto;
763
+ }
764
+
765
+ header {
766
+ margin-bottom: 30px;
767
+ padding-bottom: 20px;
768
+ border-bottom: 2px solid var(--border-color);
769
+ }
770
+
771
+ h1 {
772
+ font-size: 2em;
773
+ margin-bottom: 10px;
774
+ }
775
+
776
+ h2 {
777
+ font-size: 1.3em;
778
+ margin-bottom: 15px;
779
+ }
780
+
781
+ nav {
782
+ margin-bottom: 10px;
783
+ font-size: 0.9em;
784
+ }
785
+
786
+ nav a {
787
+ color: var(--primary-color);
788
+ text-decoration: none;
789
+ }
790
+
791
+ nav a:hover {
792
+ text-decoration: underline;
793
+ }
794
+
795
+ ul {
796
+ list-style: none;
797
+ }
798
+
799
+ li {
800
+ padding: 12px 0;
801
+ border-bottom: 1px solid var(--border-color);
802
+ }
803
+
804
+ li:last-child {
805
+ border-bottom: none;
806
+ }
807
+
808
+ li a {
809
+ color: var(--primary-color);
810
+ text-decoration: none;
811
+ font-size: 1.1em;
812
+ }
813
+
814
+ li a:hover {
815
+ text-decoration: underline;
816
+ }
817
+
818
+ .description {
819
+ color: var(--meta-color);
820
+ font-size: 0.9em;
821
+ margin-top: 4px;
822
+ }
823
+
824
+ .meta {
825
+ color: var(--meta-color);
826
+ font-size: 0.85em;
827
+ margin-top: 4px;
828
+ }
829
+
830
+ #loading {
831
+ color: var(--meta-color);
832
+ padding: 20px 0;
833
+ }
834
+
835
+ #error {
836
+ color: #cc0000;
837
+ padding: 20px;
838
+ background: rgba(255, 0, 0, 0.1);
839
+ border-radius: 6px;
840
+ border: 1px solid rgba(255, 0, 0, 0.3);
841
+ }
842
+ `;
843
+ }