@open330/kiwimu 0.7.1 → 1.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.
@@ -1,25 +1,107 @@
1
+ import { escapeHtml } from "../utils";
2
+
1
3
  interface PageLink {
2
4
  slug: string;
3
5
  title: string;
4
6
  pageType?: string;
7
+ origin?: string;
8
+ sourceUri?: string;
5
9
  }
6
10
 
7
- function escapeHtml(s: string): string {
8
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11
+ /** Configurable category definition (matches SourceCategory in config.ts). */
12
+ interface CategorySpec {
13
+ name: string;
14
+ order: number;
15
+ patterns: string[];
9
16
  }
10
17
 
11
- function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSlug?: string): string {
12
- const sourceItems = sourcePages
13
- .map(
14
- (p) =>
15
- `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`
16
- )
17
- .join("\n");
18
+ /**
19
+ * Convert a glob-like pattern (`*` wildcard) into a case-insensitive RegExp.
20
+ * Anchors are NOT applied — the result is meant to be tested against either
21
+ * the basename or the full URI, matching either substring is acceptable.
22
+ */
23
+ function patternToRegex(pat: string): RegExp {
24
+ // Escape regex specials except *
25
+ const escaped = pat.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
26
+ return new RegExp(escaped, "i");
27
+ }
28
+
29
+ /**
30
+ * Categorize a source URI against user-supplied categories. Returns the first
31
+ * matching category; falls back to "기타" / "Other" with a high order if no match.
32
+ * If `categories` is empty/undefined the caller should treat the result as
33
+ * "no grouping" (caller decides — see `groupByCategory`).
34
+ */
35
+ function categorize(
36
+ uri: string | undefined,
37
+ categories: CategorySpec[] | undefined
38
+ ): { name: string; order: number; sortKey: string } {
39
+ const u = uri || "";
40
+ const base = u.split("/").pop()?.replace(/\.[a-z0-9]+$/i, "") || "";
41
+ if (categories && categories.length > 0) {
42
+ for (const cat of categories) {
43
+ for (const pat of cat.patterns) {
44
+ const re = patternToRegex(pat);
45
+ if (re.test(base) || re.test(u)) {
46
+ return { name: cat.name, order: cat.order, sortKey: base };
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return { name: "기타", order: 9999, sortKey: base };
52
+ }
53
+
54
+ function groupByCategory(
55
+ pages: PageLink[],
56
+ categories: CategorySpec[] | undefined
57
+ ): { name: string; order: number; pages: PageLink[] }[] {
58
+ const groups = new Map<string, { name: string; order: number; pages: PageLink[] }>();
59
+ for (const p of pages) {
60
+ const c = categorize(p.sourceUri, categories);
61
+ if (!groups.has(c.name)) groups.set(c.name, { name: c.name, order: c.order, pages: [] });
62
+ groups.get(c.name)!.pages.push(p);
63
+ }
64
+ const sorted = [...groups.values()].sort((a, b) => a.order - b.order);
65
+ for (const g of sorted) {
66
+ g.pages.sort((a, b) => {
67
+ const ka = (a.sourceUri || "") + "|" + a.title;
68
+ const kb = (b.sourceUri || "") + "|" + b.title;
69
+ return ka.localeCompare(kb);
70
+ });
71
+ }
72
+ return sorted;
73
+ }
74
+
75
+ function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSlug?: string, categories?: CategorySpec[]): string {
76
+ // Group sources by category only when we have both sourceUri info AND user-defined categories.
77
+ const hasUri = sourcePages.some((p) => p.sourceUri);
78
+ const hasCats = !!(categories && categories.length > 0);
79
+ let sourceItems: string;
80
+ if (hasUri && hasCats) {
81
+ const groups = groupByCategory(sourcePages, categories);
82
+ sourceItems = groups
83
+ .map((g) => {
84
+ const items = g.pages
85
+ .map((p) => `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`)
86
+ .join("\n");
87
+ return `<details open class="sidebar-group"><summary>${escapeHtml(g.name)} <span class="sidebar-count">${g.pages.length}</span></summary><ul class="page-list">${items}</ul></details>`;
88
+ })
89
+ .join("\n");
90
+ } else {
91
+ sourceItems = sourcePages
92
+ .map(
93
+ (p) =>
94
+ `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`
95
+ )
96
+ .join("\n");
97
+ }
18
98
 
19
99
  const conceptItems = conceptPages
20
100
  .map(
21
- (p) =>
22
- `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`
101
+ (p) => {
102
+ const icon = p.origin === 'user' ? '💬' : '📝';
103
+ return `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${icon} ${escapeHtml(p.title)}</a></li>`;
104
+ }
23
105
  )
24
106
  .join("\n");
25
107
 
@@ -36,6 +118,15 @@ function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSl
36
118
  </div>
37
119
  <div class="sidebar-panel${!activeIsSource && activeSlug ? " active" : ""}" id="tab-concept">
38
120
  <ul class="page-list">${conceptItems}</ul>
121
+ </div>
122
+ <div class="sidebar-mobile-nav">
123
+ <a href="/catalog.html">📑 목록</a>
124
+ <a href="/wiki/random.html">🎲 임의 문서</a>
125
+ <a href="/quiz.html">📝 퀴즈</a>
126
+ <a href="/dashboard.html">📊 대시보드</a>
127
+ <a href="/graph.html">🔗 그래프</a>
128
+ <a href="/provenance">📚 출처</a>
129
+ <a href="/manage">⚙️ 관리</a>
39
130
  </div>`;
40
131
  }
41
132
 
@@ -45,25 +136,60 @@ function base(opts: {
45
136
  sourcePages: PageLink[];
46
137
  conceptPages: PageLink[];
47
138
  activeSlug?: string;
139
+ description?: string;
48
140
  content: string;
141
+ categories?: CategorySpec[];
49
142
  }) {
143
+ const ogDescription = escapeHtml(opts.description || 'LLM으로 자동 생성된 학습 위키');
50
144
  return `<!DOCTYPE html>
51
145
  <html lang="ko">
52
146
  <head>
53
147
  <meta charset="UTF-8">
54
148
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
55
149
  <title>${escapeHtml(opts.title)}</title>
150
+ <meta property="og:title" content="${escapeHtml(opts.title)}">
151
+ <meta property="og:description" content="${ogDescription}">
152
+ <meta property="og:type" content="article">
153
+ <meta name="twitter:card" content="summary">
154
+ <meta name="twitter:title" content="${escapeHtml(opts.title)}">
155
+ <meta name="description" content="${ogDescription}">
56
156
  <link rel="preconnect" href="https://fonts.googleapis.com">
57
157
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
58
158
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;600;700;800&display=swap" rel="stylesheet">
59
159
  <link rel="stylesheet" href="/static/style.css">
160
+ <link rel="stylesheet" href="/static/peek-panel.css">
60
161
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
61
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
62
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
162
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
163
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}]})"></script>
164
+ <script>
165
+ // Mermaid: lazy-load on first need, expose window.kiwiRenderMermaid(root)
166
+ // for static + dynamically injected content (peek panel, dynamic-qa).
167
+ (function(){
168
+ var loadPromise = null;
169
+ function load(){
170
+ if (!loadPromise) {
171
+ loadPromise = import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs').then(function(m){
172
+ m.default.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
173
+ return m.default;
174
+ });
175
+ }
176
+ return loadPromise;
177
+ }
178
+ window.kiwiRenderMermaid = function(root){
179
+ var scope = root && root.querySelectorAll ? root : document;
180
+ var nodes = scope.querySelectorAll('.mermaid:not([data-processed="true"])');
181
+ if (!nodes.length) return Promise.resolve();
182
+ return load().then(function(mermaid){
183
+ return mermaid.run({ nodes: nodes }).catch(function(e){ console.warn('mermaid render failed', e); });
184
+ });
185
+ };
186
+ document.addEventListener('DOMContentLoaded', function(){ window.kiwiRenderMermaid(); });
187
+ })();
188
+ </script>
63
189
  </head>
64
190
  <body>
65
191
  <nav class="topbar">
66
- <button class="topbar-menu-btn" aria-label="메뉴">☰</button>
192
+ <button class="topbar-menu-btn" aria-label="메뉴 열기" aria-expanded="false">☰</button>
67
193
  <a href="/index.html" class="topbar-brand">
68
194
  <img src="/static/logo.png" alt="Kiwi Mu" class="topbar-logo">
69
195
  ${escapeHtml(opts.wikiName)}
@@ -73,22 +199,27 @@ function base(opts: {
73
199
  <div id="search-results" class="search-dropdown"></div>
74
200
  </div>
75
201
  <div class="topbar-links">
76
- <a href="/wiki/random.html" style="color:#fff;text-decoration:none;font-size:13px;">🎲 임의</a>
202
+ <a href="/catalog.html" class="btn-graph">📑 목록</a>
203
+ <a href="/wiki/random.html" class="btn-graph">🎲 임의</a>
77
204
  <a href="/quiz.html" class="btn-graph">📝 퀴즈</a>
78
- <a href="/graph.html" class="btn-graph">📊 그래프</a>
79
- <a href="/admin" class="btn-graph">⚙️ 관리</a>
205
+ <a href="/dashboard.html" class="btn-graph">📊 대시보드</a>
206
+ <a href="/graph.html" class="btn-graph">🔗 그래프</a>
207
+ <a href="/manage" class="btn-graph">⚙️ 관리</a>
80
208
  </div>
81
209
  </nav>
82
210
  <div class="sidebar-overlay"></div>
83
211
  <div class="layout">
84
212
  <aside class="sidebar">
85
- ${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug)}
213
+ ${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug, opts.categories)}
86
214
  </aside>
87
215
  <main class="content">
88
216
  ${opts.content}
89
217
  </main>
90
218
  </div>
91
219
  <script src="/static/search.js"></script>
220
+ <script src="/static/dynamic-qa.js"></script>
221
+ <script src="/static/edit-page.js"></script>
222
+ <script src="/static/peek-panel.js"></script>
92
223
  <script>
93
224
  // Mobile hamburger menu
94
225
  (function() {
@@ -97,8 +228,10 @@ function base(opts: {
97
228
  const overlay = document.querySelector('.sidebar-overlay');
98
229
  if (menuBtn && sidebar) {
99
230
  menuBtn.addEventListener('click', () => {
100
- sidebar.classList.toggle('open');
231
+ const isOpen = sidebar.classList.toggle('open');
101
232
  overlay?.classList.toggle('active');
233
+ menuBtn.setAttribute('aria-expanded', isOpen);
234
+ menuBtn.setAttribute('aria-label', isOpen ? '메뉴 닫기' : '메뉴 열기');
102
235
  });
103
236
  overlay?.addEventListener('click', () => {
104
237
  sidebar.classList.remove('open');
@@ -115,18 +248,10 @@ function base(opts: {
115
248
  document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
116
249
  });
117
250
  });
118
- // KaTeX
119
- document.addEventListener("DOMContentLoaded", function() {
120
- renderMathInElement(document.body, {
121
- delimiters: [
122
- {left: "$$", right: "$$", display: true},
123
- {left: "$", right: "$", display: false},
124
- {left: "\\\\(", right: "\\\\)", display: false},
125
- {left: "\\\\[", right: "\\\\]", display: true}
126
- ]
127
- });
128
- });
129
251
  </script>
252
+ <footer class="kiwimu-badge" style="text-align:center;padding:16px;margin-top:32px;border-top:1px solid var(--border);font-size:12px;color:var(--text-muted);">
253
+ 🥝 Built with <a href="https://github.com/Open330/kiwimu" target="_blank" rel="noopener" style="color:var(--namu-green);text-decoration:none;">Kiwi Mu</a> — 나만의 학습 위키 빌더
254
+ </footer>
130
255
  </body>
131
256
  </html>`;
132
257
  }
@@ -136,15 +261,24 @@ export function renderPage(opts: {
136
261
  pageTitle: string;
137
262
  pageSlug: string;
138
263
  pageType: string;
264
+ pageId: number;
265
+ origin?: string;
139
266
  content: string;
140
267
  externalRefs: string;
141
268
  toc: string;
142
269
  backlinks: PageLink[];
270
+ citationsHtml?: string;
143
271
  sourcePages: PageLink[];
144
272
  conceptPages: PageLink[];
273
+ categories?: CategorySpec[];
145
274
  }): string {
146
- const typeLabel = opts.pageType === "source" ? "📖 원본 문서" : "📝 개념 문서";
147
- const typeBadge = `<span class="page-type-badge ${opts.pageType}">${typeLabel}</span>`;
275
+ let typeBadge: string;
276
+ if (opts.origin === 'user') {
277
+ typeBadge = `<span class="page-type-badge dynamic">💬 질문 생성</span>`;
278
+ } else {
279
+ const typeLabel = opts.pageType === "source" ? "📖 원본 문서" : "📝 개념 문서";
280
+ typeBadge = `<span class="page-type-badge ${opts.pageType}">${typeLabel}</span>`;
281
+ }
148
282
 
149
283
  // Separate backlinks by type
150
284
  const sourceBacklinks = opts.backlinks.filter((bl) => bl.pageType === "source");
@@ -174,25 +308,44 @@ export function renderPage(opts: {
174
308
  ? `<details class="toc-box" open><summary>목차</summary>${opts.toc}</details>`
175
309
  : "";
176
310
 
311
+ const citationsHtml = opts.citationsHtml || "";
312
+
177
313
  const content = `
178
- <article class="wiki-page">
314
+ <article class="wiki-page" data-page-slug="${opts.pageSlug}" data-page-id="${opts.pageId}">
179
315
  <header class="page-header">
180
316
  ${typeBadge}
181
- <h1>${escapeHtml(opts.pageTitle)}</h1>
317
+ <h1>${escapeHtml(opts.pageTitle)} <button class="edit-btn" data-slug="${opts.pageSlug}" title="편집">&#9998;</button></h1>
182
318
  </header>
183
319
  ${tocHtml}
184
320
  <div class="page-body">${opts.content}</div>
321
+ ${citationsHtml}
185
322
  ${externalRefsHtml}
186
323
  ${backlinksHtml}
187
- </article>`;
324
+ </article>
325
+ <div class="edit-modal" id="edit-modal" style="display:none">
326
+ <div class="edit-modal-inner">
327
+ <div class="edit-modal-header">
328
+ <span>페이지 편집</span>
329
+ <button class="edit-modal-close">&times;</button>
330
+ </div>
331
+ <textarea class="edit-textarea" id="edit-textarea"></textarea>
332
+ <div class="edit-modal-footer">
333
+ <button class="edit-cancel">취소</button>
334
+ <button class="edit-save">저장</button>
335
+ </div>
336
+ </div>
337
+ </div>`;
188
338
 
189
339
  return base({
190
340
  title: `${opts.pageTitle} - ${opts.wikiName}`,
191
341
  wikiName: opts.wikiName,
192
342
  sourcePages: opts.sourcePages,
193
343
  conceptPages: opts.conceptPages,
344
+ categories: opts.categories,
194
345
  activeSlug: opts.pageSlug,
346
+ description: `${opts.pageTitle} - ${opts.wikiName} 학습 위키`,
195
347
  content,
348
+ categories: opts.categories,
196
349
  });
197
350
  }
198
351
 
@@ -201,13 +354,30 @@ export function renderIndex(opts: {
201
354
  sourcePages: PageLink[];
202
355
  conceptPages: PageLink[];
203
356
  sourceCount: number;
357
+ categories?: CategorySpec[];
204
358
  }): string {
205
- const sourceCards = opts.sourcePages
206
- .map(
207
- (p) =>
208
- `<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
209
- )
210
- .join("\n");
359
+ // Group source cards only when both sourceUri AND user categories are present.
360
+ const sourceHasUri = opts.sourcePages.some((p) => p.sourceUri);
361
+ const hasCats = !!(opts.categories && opts.categories.length > 0);
362
+ const shouldGroup = sourceHasUri && hasCats;
363
+ const sourceCards = shouldGroup
364
+ ? groupByCategory(opts.sourcePages, opts.categories)
365
+ .map((g) => {
366
+ const cards = g.pages
367
+ .map(
368
+ (p) =>
369
+ `<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
370
+ )
371
+ .join("\n");
372
+ return `<div class="page-group"><h3 class="page-group-title">${escapeHtml(g.name)} <span class="page-group-count">${g.pages.length}</span></h3><div class="page-cards">${cards}</div></div>`;
373
+ })
374
+ .join("\n")
375
+ : opts.sourcePages
376
+ .map(
377
+ (p) =>
378
+ `<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
379
+ )
380
+ .join("\n");
211
381
 
212
382
  const conceptCards = opts.conceptPages
213
383
  .map(
@@ -230,12 +400,12 @@ export function renderIndex(opts: {
230
400
  <!-- Add document link -->
231
401
  <section class="index-section add-section">
232
402
  <h2>➕ 문서 추가</h2>
233
- <p>문서를 추가하려면 <a href="/admin">관리 페이지</a>에서 문서를 추가하세요.</p>
403
+ <p>문서를 추가하려면 <a href="/manage">관리 페이지</a>에서 문서를 추가하세요.</p>
234
404
  </section>
235
405
 
236
406
  <section class="index-section">
237
407
  <h2>📖 원본 문서</h2>
238
- <div class="page-cards">${sourceCards.length > 0 ? sourceCards : '<div class="empty-state">아직 원본 문서가 없습니다. URL이나 파일을 추가해보세요!</div>'}</div>
408
+ ${sourceCards.length > 0 ? (shouldGroup ? sourceCards : `<div class="page-cards">${sourceCards}</div>`) : '<div class="page-cards"><div class="empty-state">아직 원본 문서가 없습니다. URL이나 파일을 추가해보세요!</div></div>'}
239
409
  </section>
240
410
  <section class="index-section">
241
411
  <h2>📝 개념 문서</h2>
@@ -243,6 +413,7 @@ export function renderIndex(opts: {
243
413
  </section>
244
414
  <section class="index-section">
245
415
  <div class="quick-links">
416
+ <a href="/catalog.html" class="quick-link">📑 문서 목록</a>
246
417
  <a href="/quiz.html" class="quick-link">📝 학습 퀴즈</a>
247
418
  <a href="/graph.html" class="quick-link">📊 지식 그래프 보기</a>
248
419
  </div>
@@ -256,7 +427,10 @@ export function renderIndex(opts: {
256
427
  wikiName: opts.wikiName,
257
428
  sourcePages: opts.sourcePages,
258
429
  conceptPages: opts.conceptPages,
430
+ categories: opts.categories,
431
+ description: `${opts.wikiName} — LLM으로 자동 생성된 학습 위키`,
259
432
  content,
433
+ categories: opts.categories,
260
434
  });
261
435
  }
262
436
 
@@ -264,6 +438,7 @@ export function renderGraph(opts: {
264
438
  wikiName: string;
265
439
  sourcePages: PageLink[];
266
440
  conceptPages: PageLink[];
441
+ categories?: CategorySpec[];
267
442
  }): string {
268
443
  const content = `
269
444
  <div class="graph-page">
@@ -284,14 +459,16 @@ export function renderGraph(opts: {
284
459
  sourcePages: opts.sourcePages,
285
460
  conceptPages: opts.conceptPages,
286
461
  content,
462
+ categories: opts.categories,
287
463
  });
288
464
  }
289
465
 
290
466
  export function renderQuizPage(opts: {
291
467
  wikiName: string;
292
- quizzes: Array<{ id: number; question: string; answer: string; quiz_type: string; page_title?: string; page_slug?: string }>;
468
+ quizzes: Array<{ id: number; question: string; answer: string; explanation?: string; quiz_type: string; page_title?: string; page_slug?: string }>;
293
469
  sourcePages: PageLink[];
294
470
  conceptPages: PageLink[];
471
+ categories?: CategorySpec[];
295
472
  }): string {
296
473
  const quizzesJson = JSON.stringify(opts.quizzes).replace(/</g, "\\u003c");
297
474
 
@@ -327,7 +504,11 @@ export function renderQuizPage(opts: {
327
504
  <div id="quiz-result-icon" class="quiz-result-icon"></div>
328
505
  <p class="quiz-answer-label">정답</p>
329
506
  <p class="quiz-answer-text" id="quiz-answer-text"></p>
507
+ <div id="quiz-explanation" class="quiz-explanation" style="display:none;">
508
+ <p id="quiz-explanation-text" class="explanation-text"></p>
509
+ </div>
330
510
  <p class="quiz-source" id="quiz-source"></p>
511
+ <p class="quiz-review-info" id="quiz-review-info" style="display:none;"></p>
331
512
  <button id="quiz-next-btn" class="quiz-btn primary">다음 문제 →</button>
332
513
  </div>
333
514
  </div>
@@ -343,6 +524,11 @@ export function renderQuizPage(opts: {
343
524
  <div id="quiz-score-bar" class="quiz-score-bar"></div>
344
525
  </div>
345
526
  <p id="quiz-score-msg" class="quiz-score-msg"></p>
527
+ <div id="quiz-stats" class="quiz-stats" style="display:none;">
528
+ <h3>📊 학습 통계</h3>
529
+ <p id="quiz-stats-summary"></p>
530
+ <p id="quiz-stats-weak" style="display:none;"></p>
531
+ </div>
346
532
  <button id="quiz-restart-btn" class="quiz-btn primary">🔄 다시 풀기</button>
347
533
  </div>
348
534
  </div>
@@ -395,6 +581,12 @@ export function renderQuizPage(opts: {
395
581
  .quiz-score-bar-container { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin: 16px 0 20px; }
396
582
  .quiz-score-bar { height: 100%; background: var(--accent, #4caf50); border-radius: 4px; transition: width 0.5s ease; }
397
583
  .quiz-score-msg { font-size: 16px; color: var(--text-muted); margin-bottom: 24px; }
584
+ .quiz-explanation { background: var(--accent-light, #e8f5e9); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; text-align: left; }
585
+ .explanation-text { font-size: 14px; line-height: 1.6; color: var(--text, #333); margin: 0; }
586
+ .quiz-stats { background: var(--bg-alt, #f5f5f5); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 20px; text-align: left; }
587
+ .quiz-stats h3 { font-size: 15px; margin: 0 0 8px; }
588
+ .quiz-stats p { font-size: 14px; color: var(--text-muted); margin: 4px 0; }
589
+ .quiz-review-info { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; padding: 6px 12px; background: var(--accent-light, #e8f5e9); border-radius: 6px; display: inline-block; }
398
590
  </style>
399
591
  <script>
400
592
  (function() {
@@ -465,19 +657,61 @@ export function renderQuizPage(opts: {
465
657
 
466
658
  function checkAnswer(userAnswer) {
467
659
  const q = quizzes[current];
468
- const isCorrect = normalize(userAnswer) === normalize(q.answer) || normalize(q.answer).includes(normalize(userAnswer)) && normalize(userAnswer).length > 0;
660
+ const isCorrect = normalize(userAnswer) === normalize(q.answer);
469
661
 
470
662
  if (isCorrect) score++;
471
663
 
664
+ // SM-2 spaced repetition in localStorage
665
+ var quality = isCorrect ? 4 : 1;
666
+ var srsData = JSON.parse(localStorage.getItem('kiwimu-srs') || '{}');
667
+ var srs = srsData[q.id] || { ef: 2.5, interval: 0 };
668
+ if (quality >= 3) {
669
+ if (srs.interval === 0) srs.interval = 1;
670
+ else if (srs.interval === 1) srs.interval = 6;
671
+ else srs.interval = Math.round(srs.interval * srs.ef);
672
+ srs.ef = srs.ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
673
+ } else {
674
+ srs.interval = 0;
675
+ }
676
+ if (srs.ef < 1.3) srs.ef = 1.3;
677
+ var nextDate = new Date();
678
+ nextDate.setDate(nextDate.getDate() + srs.interval);
679
+ srs.nextReview = nextDate.toISOString();
680
+ srsData[q.id] = srs;
681
+ localStorage.setItem('kiwimu-srs', JSON.stringify(srsData));
682
+
683
+ // Record attempt in localStorage
684
+ var attempts = JSON.parse(localStorage.getItem('kiwimu-quiz-attempts') || '[]');
685
+ attempts.push({ quizId: q.id, isCorrect: isCorrect, quality: quality, timestamp: new Date().toISOString() });
686
+ localStorage.setItem('kiwimu-quiz-attempts', JSON.stringify(attempts));
687
+
472
688
  document.getElementById('quiz-result-icon').textContent = isCorrect ? '🎉' : '😅';
473
689
  document.getElementById('quiz-answer-text').innerHTML = esc(q.answer);
474
690
  document.getElementById('quiz-answer-text').style.color = isCorrect ? 'var(--accent, #4caf50)' : '#e53935';
475
691
 
692
+ // Show next review info
693
+ var reviewInfoEl = document.getElementById('quiz-review-info');
694
+ if (srs.interval === 0) {
695
+ reviewInfoEl.textContent = '🔄 다음 복습: 오늘';
696
+ } else {
697
+ reviewInfoEl.textContent = '📅 다음 복습: ' + srs.interval + '일 후';
698
+ }
699
+ reviewInfoEl.style.display = 'block';
700
+
701
+ // Show explanation if available
702
+ var explanationEl = document.getElementById('quiz-explanation');
703
+ if (q.explanation) {
704
+ document.getElementById('quiz-explanation-text').textContent = '💡 ' + q.explanation;
705
+ explanationEl.style.display = 'block';
706
+ } else {
707
+ explanationEl.style.display = 'none';
708
+ }
709
+
476
710
  const sourceEl = document.getElementById('quiz-source');
477
711
  if (q.page_slug) {
478
712
  const a = document.createElement('a');
479
713
  a.href = '/wiki/' + encodeURIComponent(q.page_slug) + '.html';
480
- a.textContent = q.page_title || q.page_slug;
714
+ a.textContent = '📖 ' + (q.page_title || q.page_slug) + ' 보기';
481
715
  sourceEl.textContent = '출처: ';
482
716
  sourceEl.appendChild(a);
483
717
  } else {
@@ -509,6 +743,35 @@ export function renderQuizPage(opts: {
509
743
 
510
744
  const msgs = pct >= 90 ? '🏆 완벽에 가깝습니다!' : pct >= 70 ? '👏 잘 하셨습니다!' : pct >= 50 ? '📚 조금 더 복습해보세요!' : '💪 다시 도전해보세요!';
511
745
  document.getElementById('quiz-score-msg').textContent = msgs;
746
+
747
+ // Show cumulative stats from localStorage
748
+ var allAttempts = JSON.parse(localStorage.getItem('kiwimu-quiz-attempts') || '[]');
749
+ if (allAttempts.length > 0) {
750
+ var totalAttempts = allAttempts.length;
751
+ var correctAttempts = allAttempts.filter(function(a) { return a.isCorrect; }).length;
752
+ var overallPct = Math.round(correctAttempts / totalAttempts * 100);
753
+
754
+ var statsEl = document.getElementById('quiz-stats');
755
+ statsEl.style.display = 'block';
756
+ document.getElementById('quiz-stats-summary').textContent = '전체 시도: ' + totalAttempts + '회 | 정답률: ' + overallPct + '%';
757
+
758
+ // Find weak concepts (most wrong answers by page)
759
+ var wrongByPage = {};
760
+ allAttempts.forEach(function(a) {
761
+ if (!a.isCorrect) {
762
+ var q = ALL_QUIZZES.find(function(quiz) { return quiz.id === a.quizId; });
763
+ if (q && q.page_title) {
764
+ wrongByPage[q.page_title] = (wrongByPage[q.page_title] || 0) + 1;
765
+ }
766
+ }
767
+ });
768
+ var weakConcepts = Object.keys(wrongByPage).sort(function(a, b) { return wrongByPage[b] - wrongByPage[a]; }).slice(0, 3);
769
+ if (weakConcepts.length > 0) {
770
+ var weakEl = document.getElementById('quiz-stats-weak');
771
+ weakEl.style.display = 'block';
772
+ weakEl.textContent = '💪 약한 개념: ' + weakConcepts.join(', ');
773
+ }
774
+ }
512
775
  }
513
776
 
514
777
  // Event listeners
@@ -541,6 +804,132 @@ export function renderQuizPage(opts: {
541
804
  });
542
805
  }
543
806
 
807
+ export function renderDashboardPage(opts: {
808
+ wikiName: string;
809
+ stats: { total: number; mastered: number; learning: number; new: number; dueToday: number };
810
+ weakConcepts: Array<{ title: string; slug: string; wrongCount: number }>;
811
+ recentAttempts: Array<{ quiz_id: number; question: string; is_correct: boolean; attempted_at: string }>;
812
+ sourcePages: PageLink[];
813
+ conceptPages: PageLink[];
814
+ categories?: CategorySpec[];
815
+ }): string {
816
+ const { stats } = opts;
817
+ const progressPct = stats.total > 0 ? Math.round((stats.mastered / stats.total) * 100) : 0;
818
+
819
+ const weakConceptsHtml = opts.weakConcepts.length > 0
820
+ ? opts.weakConcepts.map(c =>
821
+ `<li><a href="/wiki/${c.slug}.html">${escapeHtml(c.title)}</a> <span class="dash-weak-count">오답 ${c.wrongCount}회</span></li>`
822
+ ).join("")
823
+ : `<li class="dash-empty">아직 데이터가 없습니다.</li>`;
824
+
825
+ const recentHtml = opts.recentAttempts.length > 0
826
+ ? opts.recentAttempts.map(a => {
827
+ const icon = a.is_correct ? '✅' : '❌';
828
+ const date = a.attempted_at ? a.attempted_at.slice(0, 10) : '';
829
+ return `<li>${icon} <span class="dash-q">${escapeHtml(a.question.length > 60 ? a.question.slice(0, 57) + '...' : a.question)}</span> <span class="dash-date">${date}</span></li>`;
830
+ }).join("")
831
+ : `<li class="dash-empty">아직 시도한 퀴즈가 없습니다.</li>`;
832
+
833
+ const content = `
834
+ <div class="dash-page">
835
+ <h1>📊 학습 대시보드</h1>
836
+ <p class="dash-desc">스페이스드 리피티션(SM-2) 기반 학습 현황을 확인하세요.</p>
837
+
838
+ <div class="dash-cards">
839
+ <div class="dash-card">
840
+ <div class="dash-card-value">${stats.total}</div>
841
+ <div class="dash-card-label">전체 문제</div>
842
+ </div>
843
+ <div class="dash-card dash-card-mastered">
844
+ <div class="dash-card-value">${stats.mastered}</div>
845
+ <div class="dash-card-label">숙달</div>
846
+ </div>
847
+ <div class="dash-card dash-card-learning">
848
+ <div class="dash-card-value">${stats.learning}</div>
849
+ <div class="dash-card-label">학습중</div>
850
+ </div>
851
+ <div class="dash-card dash-card-new">
852
+ <div class="dash-card-value">${stats.new}</div>
853
+ <div class="dash-card-label">새 문제</div>
854
+ </div>
855
+ <div class="dash-card dash-card-due">
856
+ <div class="dash-card-value">${stats.dueToday}</div>
857
+ <div class="dash-card-label">오늘 복습</div>
858
+ </div>
859
+ </div>
860
+
861
+ <div class="dash-progress-section">
862
+ <h2>📈 숙달 진행률</h2>
863
+ <div class="dash-progress-bar-container">
864
+ <div class="dash-progress-bar" style="width:${progressPct}%"></div>
865
+ </div>
866
+ <p class="dash-progress-text">${stats.mastered} / ${stats.total} 문제 숙달 (${progressPct}%)</p>
867
+ </div>
868
+
869
+ <div class="dash-columns">
870
+ <div class="dash-section">
871
+ <h2>💪 약한 개념</h2>
872
+ <ul class="dash-list">${weakConceptsHtml}</ul>
873
+ </div>
874
+ <div class="dash-section">
875
+ <h2>🕐 최근 시도</h2>
876
+ <ul class="dash-list">${recentHtml}</ul>
877
+ </div>
878
+ </div>
879
+
880
+ <div class="dash-action">
881
+ <a href="/quiz.html" class="dash-review-btn">📝 복습 시작</a>
882
+ </div>
883
+ </div>
884
+ <style>
885
+ .dash-page { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
886
+ .dash-page h1 { font-size: 24px; margin-bottom: 8px; }
887
+ .dash-page h2 { font-size: 18px; margin-bottom: 12px; }
888
+ .dash-desc { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
889
+ .dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 28px; }
890
+ .dash-card {
891
+ background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 10px;
892
+ padding: 16px; text-align: center;
893
+ }
894
+ .dash-card-value { font-size: 28px; font-weight: 800; color: var(--text); }
895
+ .dash-card-label { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
896
+ .dash-card-mastered .dash-card-value { color: #2e7d32; }
897
+ .dash-card-learning .dash-card-value { color: #f9a825; }
898
+ .dash-card-new .dash-card-value { color: #1565c0; }
899
+ .dash-card-due .dash-card-value { color: #e53935; }
900
+ .dash-progress-section { margin-bottom: 28px; }
901
+ .dash-progress-bar-container { height: 10px; background: var(--border); border-radius: 5px; overflow: hidden; margin: 8px 0; }
902
+ .dash-progress-bar { height: 100%; background: #2e7d32; border-radius: 5px; transition: width 0.5s ease; }
903
+ .dash-progress-text { font-size: 14px; color: var(--text-muted); }
904
+ .dash-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; }
905
+ @media (max-width: 600px) { .dash-columns { grid-template-columns: 1fr; } }
906
+ .dash-section { background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
907
+ .dash-list { list-style: none; padding: 0; margin: 0; }
908
+ .dash-list li { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 14px; display: flex; align-items: center; gap: 8px; }
909
+ .dash-list li:last-child { border-bottom: none; }
910
+ .dash-list a { color: var(--accent, #4caf50); text-decoration: none; }
911
+ .dash-list a:hover { text-decoration: underline; }
912
+ .dash-weak-count { font-size: 12px; color: #e53935; margin-left: auto; white-space: nowrap; }
913
+ .dash-date { font-size: 12px; color: var(--text-muted); margin-left: auto; white-space: nowrap; }
914
+ .dash-q { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
915
+ .dash-empty { color: var(--text-muted); font-style: italic; }
916
+ .dash-action { text-align: center; margin-top: 8px; }
917
+ .dash-review-btn {
918
+ display: inline-block; padding: 12px 32px; background: var(--accent, #4caf50); color: white;
919
+ border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none; transition: opacity 0.2s;
920
+ }
921
+ .dash-review-btn:hover { opacity: 0.9; }
922
+ </style>`;
923
+
924
+ return base({
925
+ title: `📊 학습 대시보드 — ${opts.wikiName}`,
926
+ wikiName: opts.wikiName,
927
+ sourcePages: opts.sourcePages,
928
+ conceptPages: opts.conceptPages,
929
+ content,
930
+ });
931
+ }
932
+
544
933
  export function renderAdmin(opts: {
545
934
  wikiName: string;
546
935
  sources: Array<{ id: number; uri: string; type: string; title: string; fetched_at: string }>;
@@ -602,7 +991,7 @@ export function renderAdmin(opts: {
602
991
  <div class="topbar-links">
603
992
  <a href="/index.html" class="btn-graph">🏠 홈</a>
604
993
  <a href="/graph.html" class="btn-graph">📊 그래프</a>
605
- <a href="/admin" class="btn-graph" style="border-color: var(--accent);">⚙️ 관리</a>
994
+ <a href="/manage" class="btn-graph" style="border-color: var(--accent);">⚙️ 관리</a>
606
995
  </div>
607
996
  </nav>
608
997
  <div class="admin-page">
@@ -634,7 +1023,7 @@ export function renderAdmin(opts: {
634
1023
  </div>
635
1024
  <div class="config-row">
636
1025
  <span class="config-key">모델</span>
637
- <input id="llm-model" class="config-input" value="${escapeHtml(opts.llmConfig.model)}" placeholder="gemini-2.0-flash-lite">
1026
+ <input id="llm-model" class="config-input" value="${escapeHtml(opts.llmConfig.model)}" placeholder="gemini-3.1-flash-lite-preview">
638
1027
  </div>
639
1028
  <div class="config-row">
640
1029
  <span class="config-key">API Key</span>
@@ -781,7 +1170,7 @@ export function renderAdmin(opts: {
781
1170
 
782
1171
  document.getElementById('llm-provider').addEventListener('change', (e) => {
783
1172
  document.getElementById('endpoint-row').style.display = e.target.value === 'azure-openai' ? '' : 'none';
784
- const models = { gemini: 'gemini-2.0-flash-lite', 'azure-openai': 'gpt-5-nano', openai: 'gpt-4o-mini', anthropic: 'claude-sonnet-4-20250514' };
1173
+ const models = { gemini: 'gemini-3.1-flash-lite-preview', 'azure-openai': 'gpt-5.4-nano', openai: 'gpt-5.4-nano', anthropic: 'claude-sonnet-4-6' };
785
1174
  document.getElementById('llm-model').placeholder = models[e.target.value] || '';
786
1175
  });
787
1176
  document.getElementById('llm-form').addEventListener('submit', async (e) => {
@@ -867,3 +1256,322 @@ export function renderAdmin(opts: {
867
1256
  </body>
868
1257
  </html>`;
869
1258
  }
1259
+
1260
+ export function renderCatalogPage(opts: {
1261
+ wikiName: string;
1262
+ categories: Array<{
1263
+ name: string;
1264
+ slug: string;
1265
+ description?: string;
1266
+ pages: Array<{ id: number; title: string; slug: string; type: string; linkCount: number }>;
1267
+ }>;
1268
+ totalPages: number;
1269
+ totalLinks: number;
1270
+ generatedAt: string;
1271
+ sourcePages: PageLink[];
1272
+ conceptPages: PageLink[];
1273
+ }): string {
1274
+ const categoriesHtml = opts.categories.map((cat) => {
1275
+ const pagesHtml = cat.pages.map((p) => {
1276
+ const typeBadge = p.type === 'source'
1277
+ ? '<span class="catalog-badge source">📖 원본</span>'
1278
+ : '<span class="catalog-badge concept">📝 개념</span>';
1279
+ const linkBadge = p.linkCount > 0
1280
+ ? `<span class="catalog-link-count" title="연결된 문서 수">🔗 ${p.linkCount}</span>`
1281
+ : '';
1282
+ return `<li class="catalog-item" data-title="${escapeHtml(p.title.toLowerCase())}">
1283
+ <a href="/wiki/${p.slug}.html">${escapeHtml(p.title)}</a>
1284
+ ${typeBadge}
1285
+ ${linkBadge}
1286
+ </li>`;
1287
+ }).join("\n");
1288
+
1289
+ return `
1290
+ <details class="catalog-category" open>
1291
+ <summary class="catalog-category-header">
1292
+ <span class="catalog-category-name">${escapeHtml(cat.name)}</span>
1293
+ <span class="catalog-category-count">${cat.pages.length}개 문서</span>
1294
+ </summary>
1295
+ ${cat.description ? `<p class="catalog-category-desc">${escapeHtml(cat.description)}</p>` : ''}
1296
+ <ul class="catalog-list">${pagesHtml}</ul>
1297
+ </details>`;
1298
+ }).join("\n");
1299
+
1300
+ const content = `
1301
+ <div class="catalog-page">
1302
+ <h1>📑 문서 목록</h1>
1303
+ <p class="catalog-desc">전체 ${opts.totalPages}개 문서 · ${opts.totalLinks}개 링크 · ${opts.categories.length}개 카테고리</p>
1304
+
1305
+ <div class="catalog-filter">
1306
+ <input type="text" id="catalog-search" placeholder="문서 이름으로 검색..." autocomplete="off">
1307
+ </div>
1308
+
1309
+ <div id="catalog-categories">
1310
+ ${categoriesHtml || '<p class="catalog-empty">아직 문서가 없습니다. 소스를 추가하면 자동으로 목록이 생성됩니다.</p>'}
1311
+ </div>
1312
+ </div>
1313
+ <script>
1314
+ (function() {
1315
+ const input = document.getElementById('catalog-search');
1316
+ if (!input) return;
1317
+ input.addEventListener('input', function() {
1318
+ const q = this.value.toLowerCase().trim();
1319
+ document.querySelectorAll('.catalog-item').forEach(function(item) {
1320
+ const title = item.getAttribute('data-title') || '';
1321
+ item.style.display = (!q || title.includes(q)) ? '' : 'none';
1322
+ });
1323
+ // Hide empty categories
1324
+ document.querySelectorAll('.catalog-category').forEach(function(cat) {
1325
+ const visible = cat.querySelectorAll('.catalog-item[style=""], .catalog-item:not([style])');
1326
+ const allItems = cat.querySelectorAll('.catalog-item');
1327
+ let visibleCount = 0;
1328
+ allItems.forEach(function(item) { if (item.style.display !== 'none') visibleCount++; });
1329
+ cat.style.display = (q && visibleCount === 0) ? 'none' : '';
1330
+ });
1331
+ });
1332
+ })();
1333
+ </script>`;
1334
+
1335
+ return base({
1336
+ title: `문서 목록 - ${opts.wikiName}`,
1337
+ wikiName: opts.wikiName,
1338
+ sourcePages: opts.sourcePages,
1339
+ conceptPages: opts.conceptPages,
1340
+ // NOTE: renderCatalogPage's `opts.categories` has its own shape
1341
+ // (catalog UI categories with `.pages`), distinct from SourceCategory.
1342
+ // We deliberately don't forward it to `base()` to avoid the name clash;
1343
+ // catalog page's sidebar therefore uses the flat fallback rendering.
1344
+ description: `${opts.wikiName} 전체 문서 목록 — ${opts.totalPages}개 문서`,
1345
+ content,
1346
+ });
1347
+ }
1348
+
1349
+ export function renderProvenancePage(opts: {
1350
+ wikiName: string;
1351
+ coverage: Array<{
1352
+ sourceId: number;
1353
+ sourceTitle: string;
1354
+ citationCount: number;
1355
+ pageCount: number;
1356
+ pages: Array<{ title: string; slug: string }>;
1357
+ }>;
1358
+ sourcePages: PageLink[];
1359
+ conceptPages: PageLink[];
1360
+ categories?: CategorySpec[];
1361
+ }): string {
1362
+ const totalCitations = opts.coverage.reduce((s, c) => s + c.citationCount, 0);
1363
+ const totalSourcesCited = opts.coverage.filter(c => c.citationCount > 0).length;
1364
+
1365
+ const rows = opts.coverage.map(c => {
1366
+ const pageLinks = c.pages.map(p =>
1367
+ `<a href="/wiki/${p.slug}.html" class="provenance-page-link">${escapeHtml(p.title)}</a>`
1368
+ ).join(", ") || '<span class="text-muted">-</span>';
1369
+
1370
+ const barWidth = totalCitations > 0 ? Math.max(2, Math.round((c.citationCount / totalCitations) * 100)) : 0;
1371
+ const barColor = c.citationCount === 0 ? '#e0e0e0' : c.citationCount < 3 ? '#ffc107' : '#28a745';
1372
+
1373
+ return `<tr>
1374
+ <td>${escapeHtml(c.sourceTitle || 'Untitled')}</td>
1375
+ <td class="text-center">${c.citationCount}</td>
1376
+ <td class="text-center">${c.pageCount}</td>
1377
+ <td><div class="provenance-bar" style="width:${barWidth}%;background:${barColor}"></div></td>
1378
+ <td class="provenance-pages">${pageLinks}</td>
1379
+ </tr>`;
1380
+ }).join("\n");
1381
+
1382
+ const content = `
1383
+ <div class="provenance-page">
1384
+ <h1>Source Provenance</h1>
1385
+ <p class="provenance-summary">
1386
+ ${totalCitations} citations across ${totalSourcesCited}/${opts.coverage.length} sources
1387
+ </p>
1388
+
1389
+ <table class="provenance-table">
1390
+ <thead>
1391
+ <tr>
1392
+ <th>Source</th>
1393
+ <th class="text-center">Citations</th>
1394
+ <th class="text-center">Pages</th>
1395
+ <th>Coverage</th>
1396
+ <th>Citing Pages</th>
1397
+ </tr>
1398
+ </thead>
1399
+ <tbody>
1400
+ ${rows}
1401
+ </tbody>
1402
+ </table>
1403
+
1404
+ ${opts.coverage.some(c => c.citationCount === 0) ? `
1405
+ <div class="provenance-warning">
1406
+ <strong>Uncited sources:</strong>
1407
+ ${opts.coverage.filter(c => c.citationCount === 0).map(c => escapeHtml(c.sourceTitle || 'Untitled')).join(", ")}
1408
+ <br><small>Run <code>kiwimu cite</code> to retroactively generate citations for existing content.</small>
1409
+ </div>` : ''}
1410
+ </div>
1411
+
1412
+ <style>
1413
+ .provenance-page { max-width: 960px; margin: 0 auto; }
1414
+ .provenance-page h1 { margin-bottom: 8px; }
1415
+ .provenance-summary { color: var(--text-muted); margin-bottom: 24px; }
1416
+ .provenance-table { width: 100%; border-collapse: collapse; font-size: 14px; }
1417
+ .provenance-table th, .provenance-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; }
1418
+ .provenance-table th { font-weight: 600; background: var(--bg-secondary, #f8f9fa); }
1419
+ .text-center { text-align: center !important; }
1420
+ .text-muted { color: var(--text-muted, #999); }
1421
+ .provenance-bar { height: 8px; border-radius: 4px; min-width: 2px; }
1422
+ .provenance-pages { font-size: 12px; }
1423
+ .provenance-page-link { display: inline-block; margin: 2px 4px 2px 0; padding: 1px 6px; background: var(--bg-secondary, #f0f0f0); border-radius: 3px; text-decoration: none; color: var(--namu-green, #2e7d32); }
1424
+ .provenance-page-link:hover { background: var(--namu-green, #2e7d32); color: white; }
1425
+ .provenance-warning { margin-top: 24px; padding: 12px 16px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; font-size: 13px; }
1426
+ </style>`;
1427
+
1428
+ return base({
1429
+ title: `Source Provenance - ${opts.wikiName}`,
1430
+ wikiName: opts.wikiName,
1431
+ sourcePages: opts.sourcePages,
1432
+ conceptPages: opts.conceptPages,
1433
+ content,
1434
+ });
1435
+ }
1436
+
1437
+ export function renderActivityPage(
1438
+ authToken: string,
1439
+ wikiName: string,
1440
+ stats: { total: number; byAction: Record<string, number>; recentDays: { date: string; count: number }[] }
1441
+ ): string {
1442
+ const actionIcons: Record<string, string> = {
1443
+ ingest: "\u{1F4E5}", page_created: "\u{1F4C4}", page_updated: "\u270F\uFE0F", quiz_generated: "\u{1F9E9}",
1444
+ quiz_attempted: "\u{1F4DD}", query: "\u2753", build: "\u{1F528}", deploy: "\u{1F680}", expand: "\u{1F9E0}",
1445
+ };
1446
+ const actionLabels: Record<string, string> = {
1447
+ ingest: "Ingest", page_created: "Page Created", page_updated: "Page Updated",
1448
+ quiz_generated: "Quiz Generated", quiz_attempted: "Quiz Attempted", query: "Q&A",
1449
+ build: "Build", deploy: "Deploy", expand: "Expand",
1450
+ };
1451
+ const filterButtons = Object.entries(stats.byAction)
1452
+ .map(([action, count]) => `<button class="filter-btn" data-action="${action}">${actionIcons[action] || "\u{1F4CC}"} ${actionLabels[action] || action} <span class="count">(${count})</span></button>`)
1453
+ .join("\n ");
1454
+
1455
+ return `<!DOCTYPE html>
1456
+ <html lang="ko">
1457
+ <head>
1458
+ <meta charset="UTF-8">
1459
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1460
+ <meta name="kiwi-auth" content="${authToken}">
1461
+ <title>Activity Log - ${wikiName}</title>
1462
+ <style>
1463
+ :root { --bg: #fff; --fg: #1a1a2e; --card-bg: #f8f9fa; --border: #e0e0e0; --accent: #4a90d9; --muted: #6c757d; --badge-bg: #e8f0fe; --badge-fg: #1a73e8; }
1464
+ @media (prefers-color-scheme: dark) {
1465
+ :root { --bg: #1a1a2e; --fg: #e0e0e0; --card-bg: #16213e; --border: #2a2a4a; --accent: #64b5f6; --muted: #9e9e9e; --badge-bg: #1e3a5f; --badge-fg: #90caf9; }
1466
+ }
1467
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1468
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; }
1469
+ .container { max-width: 860px; margin: 0 auto; padding: 2rem 1rem; }
1470
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
1471
+ .subtitle { color: var(--muted); margin-bottom: 1.5rem; }
1472
+ .filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
1473
+ .filter-btn { background: var(--card-bg); border: 1px solid var(--border); border-radius: 1rem; padding: 0.3rem 0.8rem; cursor: pointer; font-size: 0.85rem; color: var(--fg); transition: all 0.15s; }
1474
+ .filter-btn:hover, .filter-btn.active { background: var(--badge-bg); color: var(--badge-fg); border-color: var(--accent); }
1475
+ .filter-btn .count { color: var(--muted); font-size: 0.75rem; }
1476
+ .timeline { list-style: none; border-left: 2px solid var(--border); padding-left: 1.5rem; }
1477
+ .timeline-item { position: relative; padding: 0.75rem 0; }
1478
+ .timeline-item::before { content: ""; position: absolute; left: -1.75rem; top: 1.1rem; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid var(--bg); }
1479
+ .timeline-item .time { font-size: 0.75rem; color: var(--muted); }
1480
+ .timeline-item .badge { display: inline-block; background: var(--badge-bg); color: var(--badge-fg); font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 0.75rem; margin-left: 0.5rem; }
1481
+ .timeline-item .title { font-weight: 500; margin-top: 0.15rem; }
1482
+ .timeline-item .details { font-size: 0.8rem; color: var(--muted); margin-top: 0.15rem; }
1483
+ .load-more { display: block; width: 100%; padding: 0.6rem; margin-top: 1rem; background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; cursor: pointer; color: var(--fg); font-size: 0.9rem; text-align: center; }
1484
+ .load-more:hover { background: var(--badge-bg); }
1485
+ .empty { text-align: center; color: var(--muted); padding: 3rem; }
1486
+ a.back { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
1487
+ a.back:hover { text-decoration: underline; }
1488
+ </style>
1489
+ </head>
1490
+ <body>
1491
+ <div class="container">
1492
+ <a class="back" href="/">&larr; Back to Wiki</a>
1493
+ <h1>Activity Log</h1>
1494
+ <p class="subtitle">${stats.total} total events</p>
1495
+ <div class="filters">
1496
+ <button class="filter-btn active" data-action="">All (${stats.total})</button>
1497
+ ${filterButtons}
1498
+ </div>
1499
+ <ul class="timeline" id="timeline"></ul>
1500
+ <button class="load-more" id="load-more">Load more</button>
1501
+ <div class="empty" id="empty" style="display:none;">No activity yet.</div>
1502
+ </div>
1503
+ <script>
1504
+ const authToken = document.querySelector('meta[name="kiwi-auth"]')?.content || '';
1505
+ const icons = ${JSON.stringify(actionIcons)};
1506
+ const labels = ${JSON.stringify(actionLabels)};
1507
+ let currentAction = '';
1508
+ let offset = 0;
1509
+ const limit = 50;
1510
+
1511
+ function formatTime(iso) {
1512
+ const d = new Date(iso + 'Z');
1513
+ const now = new Date();
1514
+ const diff = now - d;
1515
+ if (diff < 60000) return 'just now';
1516
+ if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
1517
+ if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
1518
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1519
+ }
1520
+
1521
+ function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
1522
+
1523
+ function renderEntry(e) {
1524
+ const icon = icons[e.action] || '\u{1F4CC}';
1525
+ const label = labels[e.action] || e.action;
1526
+ let detailsHtml = '';
1527
+ if (e.details) {
1528
+ try {
1529
+ const d = JSON.parse(e.details);
1530
+ detailsHtml = '<span class="details">' + Object.entries(d).map(([k,v]) => esc(k) + ': ' + esc(String(v).slice(0,60))).join(' | ') + '</span>';
1531
+ } catch {}
1532
+ }
1533
+ return '<li class="timeline-item" data-action="' + esc(e.action) + '">' +
1534
+ '<span class="time">' + formatTime(e.created_at) + '</span>' +
1535
+ '<span class="badge">' + icon + ' ' + esc(label) + '</span>' +
1536
+ '<div class="title">' + esc(e.title || '') + '</div>' +
1537
+ detailsHtml + '</li>';
1538
+ }
1539
+
1540
+ async function loadEntries(append) {
1541
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset), token: authToken });
1542
+ if (currentAction) params.set('action', currentAction);
1543
+ const res = await fetch('/api/activity?' + params);
1544
+ const data = await res.json();
1545
+ const entries = data.entries;
1546
+ const tl = document.getElementById('timeline');
1547
+ if (!append) tl.innerHTML = '';
1548
+ if (entries.length === 0 && offset === 0) {
1549
+ document.getElementById('empty').style.display = '';
1550
+ document.getElementById('load-more').style.display = 'none';
1551
+ } else {
1552
+ document.getElementById('empty').style.display = 'none';
1553
+ document.getElementById('load-more').style.display = entries.length < limit ? 'none' : '';
1554
+ tl.innerHTML += entries.map(renderEntry).join('');
1555
+ }
1556
+ }
1557
+
1558
+ document.querySelectorAll('.filter-btn').forEach(btn => {
1559
+ btn.addEventListener('click', () => {
1560
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
1561
+ btn.classList.add('active');
1562
+ currentAction = btn.dataset.action;
1563
+ offset = 0;
1564
+ loadEntries(false);
1565
+ });
1566
+ });
1567
+
1568
+ document.getElementById('load-more').addEventListener('click', () => {
1569
+ offset += limit;
1570
+ loadEntries(true);
1571
+ });
1572
+
1573
+ loadEntries(false);
1574
+ </script>
1575
+ </body>
1576
+ </html>`;
1577
+ }