@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.
- package/README.md +189 -62
- package/package.json +1 -1
- package/src/build/renderer.ts +273 -32
- package/src/build/static/dynamic-qa.js +423 -0
- package/src/build/static/edit-page.js +58 -0
- package/src/build/static/peek-panel.css +201 -0
- package/src/build/static/peek-panel.js +470 -0
- package/src/build/static/search.js +30 -15
- package/src/build/static/style.css +821 -6
- package/src/build/templates.ts +757 -49
- package/src/config.ts +41 -3
- package/src/demo/sample-data.ts +75 -8
- package/src/demo/setup.ts +26 -7
- package/src/expand/llm.ts +2 -2
- package/src/index.ts +497 -64
- package/src/ingest/docx.ts +1 -1
- package/src/ingest/markdown.ts +21 -0
- package/src/ingest/pdf.ts +4 -2
- package/src/llm-client.ts +63 -69
- package/src/pipeline/citations.ts +107 -0
- package/src/pipeline/llm-chunker.ts +281 -128
- package/src/pipeline/standardizer.ts +41 -0
- package/src/server.ts +466 -33
- package/src/services/dynamic-qa.ts +190 -0
- package/src/services/embedding.ts +122 -0
- package/src/services/index-generator.ts +185 -0
- package/src/services/ingest.ts +84 -26
- package/src/services/lint.ts +249 -0
- package/src/services/promote.ts +150 -0
- package/src/store.test.ts +11 -0
- package/src/store.ts +652 -15
- package/src/utils.ts +30 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const authToken = document.querySelector('meta[name="kiwi-auth"]')?.content;
|
|
5
|
+
if (!authToken) return; // Static build β feature disabled
|
|
6
|
+
|
|
7
|
+
const pageSlug = document.querySelector('[data-page-slug]')?.dataset.pageSlug;
|
|
8
|
+
const pageId = document.querySelector('[data-page-id]')?.dataset.pageId;
|
|
9
|
+
if (!pageSlug || !pageId) return; // Not a wiki page
|
|
10
|
+
|
|
11
|
+
// Create popover element
|
|
12
|
+
const popover = document.createElement('div');
|
|
13
|
+
popover.className = 'qa-popover';
|
|
14
|
+
popover.innerHTML = `
|
|
15
|
+
<div class="qa-popover-header">
|
|
16
|
+
<span>π μ κ°λ
νμ΄μ§ μμ±</span>
|
|
17
|
+
<button class="qa-popover-close" aria-label="λ«κΈ°">×</button>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="qa-popover-selected"></div>
|
|
20
|
+
<div class="qa-popover-related" style="display:none">
|
|
21
|
+
<div class="qa-related-label">π κ΄λ ¨ λ¬Έμ</div>
|
|
22
|
+
<div class="qa-related-list"></div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="qa-popover-body">
|
|
25
|
+
<div class="qa-body-row">
|
|
26
|
+
<input type="text" class="qa-popover-input" placeholder="μ§λ¬Έ μ
λ ₯ (μ νμ¬ν)..." />
|
|
27
|
+
</div>
|
|
28
|
+
<div class="qa-body-row">
|
|
29
|
+
<button class="qa-popover-btn qa-generate-btn">β¨ κ°λ
νμ΄μ§ μμ±</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="qa-popover-loading" style="display:none">
|
|
33
|
+
<span class="qa-spinner"></span> μμ± μ€...
|
|
34
|
+
</div>
|
|
35
|
+
<div class="qa-popover-result" style="display:none"></div>
|
|
36
|
+
<div class="qa-popover-error" style="display:none"></div>
|
|
37
|
+
`;
|
|
38
|
+
document.body.appendChild(popover);
|
|
39
|
+
|
|
40
|
+
const generateBtn = popover.querySelector('.qa-generate-btn');
|
|
41
|
+
const loading = popover.querySelector('.qa-popover-loading');
|
|
42
|
+
const result = popover.querySelector('.qa-popover-result');
|
|
43
|
+
const errorDiv = popover.querySelector('.qa-popover-error');
|
|
44
|
+
const selectedDiv = popover.querySelector('.qa-popover-selected');
|
|
45
|
+
const questionInput = popover.querySelector('.qa-popover-input');
|
|
46
|
+
let selectedText = '';
|
|
47
|
+
let isGenerating = false;
|
|
48
|
+
let highlightMark = null;
|
|
49
|
+
let popoverTimer = null;
|
|
50
|
+
|
|
51
|
+
popover.querySelector('.qa-popover-close').addEventListener('click', hidePopover);
|
|
52
|
+
|
|
53
|
+
// Enter key in question input triggers generate
|
|
54
|
+
questionInput.addEventListener('keydown', (e) => {
|
|
55
|
+
if (e.key === 'Enter') {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
generateConcept();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function highlightSelection(range) {
|
|
62
|
+
try {
|
|
63
|
+
removeHighlight();
|
|
64
|
+
highlightMark = document.createElement('mark');
|
|
65
|
+
highlightMark.className = 'qa-highlight';
|
|
66
|
+
range.surroundContents(highlightMark);
|
|
67
|
+
} catch {
|
|
68
|
+
highlightMark = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function removeHighlight() {
|
|
73
|
+
if (highlightMark && highlightMark.parentNode) {
|
|
74
|
+
const parent = highlightMark.parentNode;
|
|
75
|
+
while (highlightMark.firstChild) {
|
|
76
|
+
parent.insertBefore(highlightMark.firstChild, highlightMark);
|
|
77
|
+
}
|
|
78
|
+
parent.removeChild(highlightMark);
|
|
79
|
+
highlightMark = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function showPopover(text, rect, range) {
|
|
84
|
+
selectedText = text;
|
|
85
|
+
loading.style.display = 'none';
|
|
86
|
+
result.style.display = 'none';
|
|
87
|
+
errorDiv.style.display = 'none';
|
|
88
|
+
popover.querySelector('.qa-popover-body').style.display = 'flex';
|
|
89
|
+
popover.querySelector('.qa-popover-header').style.display = 'flex';
|
|
90
|
+
|
|
91
|
+
// Show selected text preview (truncated)
|
|
92
|
+
const preview = text.length > 100 ? text.slice(0, 100) + '...' : text;
|
|
93
|
+
selectedDiv.textContent = `"${preview}"`;
|
|
94
|
+
selectedDiv.style.display = 'block';
|
|
95
|
+
|
|
96
|
+
// Highlight selected text in the document
|
|
97
|
+
highlightSelection(range);
|
|
98
|
+
|
|
99
|
+
// Fetch related pages
|
|
100
|
+
const relatedDiv = popover.querySelector('.qa-popover-related');
|
|
101
|
+
const relatedList = popover.querySelector('.qa-related-list');
|
|
102
|
+
relatedDiv.style.display = 'none';
|
|
103
|
+
relatedList.innerHTML = '';
|
|
104
|
+
|
|
105
|
+
fetch(`/api/search?q=${encodeURIComponent(text.slice(0, 100))}&token=${authToken}`)
|
|
106
|
+
.then(r => r.json())
|
|
107
|
+
.then(data => {
|
|
108
|
+
if (data.results && data.results.length > 0) {
|
|
109
|
+
relatedList.innerHTML = data.results.map(r => {
|
|
110
|
+
const icon = r.origin === 'user' ? 'π¬' : r.page_type === 'source' ? 'π' : 'π';
|
|
111
|
+
const preview = r.preview ? r.preview.slice(0, 80) + '...' : '';
|
|
112
|
+
return `<a href="/wiki/${encodeURIComponent(r.slug)}.html" class="qa-related-item">
|
|
113
|
+
<span class="qa-related-icon">${icon}</span>
|
|
114
|
+
<span class="qa-related-title">${esc(r.title)}</span>
|
|
115
|
+
<span class="qa-related-preview">${esc(preview)}</span>
|
|
116
|
+
</a>`;
|
|
117
|
+
}).join('');
|
|
118
|
+
relatedDiv.style.display = 'block';
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {}); // Silently fail
|
|
122
|
+
|
|
123
|
+
// Position below selection
|
|
124
|
+
const top = rect.bottom + window.scrollY + 8;
|
|
125
|
+
const left = Math.max(8, Math.min(rect.left + window.scrollX, window.innerWidth - 320));
|
|
126
|
+
popover.style.top = top + 'px';
|
|
127
|
+
popover.style.left = left + 'px';
|
|
128
|
+
popover.style.display = 'block';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hidePopover() {
|
|
132
|
+
popover.style.display = 'none';
|
|
133
|
+
selectedText = '';
|
|
134
|
+
selectedDiv.style.display = 'none';
|
|
135
|
+
removeHighlight();
|
|
136
|
+
clearTimeout(popoverTimer);
|
|
137
|
+
popoverTimer = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function esc(s) {
|
|
141
|
+
const d = document.createElement('div');
|
|
142
|
+
d.textContent = s;
|
|
143
|
+
return d.innerHTML;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function generateConcept() {
|
|
147
|
+
if (isGenerating || !selectedText) return;
|
|
148
|
+
|
|
149
|
+
isGenerating = true;
|
|
150
|
+
popover.querySelector('.qa-popover-body').style.display = 'none';
|
|
151
|
+
loading.style.display = 'flex';
|
|
152
|
+
errorDiv.style.display = 'none';
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Start background generation
|
|
156
|
+
const resp = await fetch('/api/ask', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: {
|
|
159
|
+
'Content-Type': 'application/json',
|
|
160
|
+
'Authorization': 'Bearer ' + authToken
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
selected_text: selectedText,
|
|
164
|
+
question: questionInput.value.trim() || undefined,
|
|
165
|
+
page_slug: pageSlug,
|
|
166
|
+
page_id: parseInt(pageId)
|
|
167
|
+
})
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const data = await resp.json();
|
|
171
|
+
|
|
172
|
+
if (data.task_id) {
|
|
173
|
+
// Background task started β poll for completion
|
|
174
|
+
pollForResult(data.task_id);
|
|
175
|
+
} else if (data.ok) {
|
|
176
|
+
// Synchronous result (fallback)
|
|
177
|
+
showResult(data);
|
|
178
|
+
} else {
|
|
179
|
+
showError(data.error || 'μ€λ₯κ° λ°μνμ΅λλ€');
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
showError('μλ² μ°κ²°μ μ€ν¨νμ΅λλ€');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function pollForResult(taskId) {
|
|
187
|
+
const maxAttempts = 60; // 60 * 2s = 2 min max
|
|
188
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
189
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
190
|
+
try {
|
|
191
|
+
const resp = await fetch(`/api/ask/status?task_id=${taskId}`, {
|
|
192
|
+
headers: { 'Authorization': 'Bearer ' + authToken }
|
|
193
|
+
});
|
|
194
|
+
const data = await resp.json();
|
|
195
|
+
|
|
196
|
+
if (data.status === 'completed') {
|
|
197
|
+
showResult(data.result);
|
|
198
|
+
return;
|
|
199
|
+
} else if (data.status === 'error') {
|
|
200
|
+
showError(data.error || 'μμ± μ€ μ€λ₯κ° λ°μνμ΅λλ€');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// status === 'processing' β continue polling
|
|
204
|
+
loading.querySelector('span:last-child')?.remove();
|
|
205
|
+
const dots = '.'.repeat((i % 3) + 1);
|
|
206
|
+
loading.innerHTML = `<span class="qa-spinner"></span> μμ± μ€${dots}`;
|
|
207
|
+
} catch {
|
|
208
|
+
// Network error during poll β keep trying
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
showError('μμ± μκ°μ΄ μ΄κ³Όλμμ΅λλ€. λμ€μ λ€μ μλν΄μ£ΌμΈμ.');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function showResult(data) {
|
|
215
|
+
loading.style.display = 'none';
|
|
216
|
+
isGenerating = false;
|
|
217
|
+
|
|
218
|
+
let html = `<a href="${data.url}" class="qa-result-link">π ${esc(data.title)}</a><span class="qa-result-hint">μ κ°λ
νμ΄μ§κ° μμ±λμμ΅λλ€</span>`;
|
|
219
|
+
|
|
220
|
+
// Show "Save to Wiki" button if the answer is promotable
|
|
221
|
+
if (data.isPromotable) {
|
|
222
|
+
html += `<button class="qa-promote-btn" title="ν΄μ¦μ μν€ λ§ν¬κ° ν¬ν¨λ μꡬ νμ΄μ§λ‘ μ μ₯ν©λλ€">πΎ μν€μ μ μ₯</button>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
result.innerHTML = html;
|
|
226
|
+
result.style.display = 'block';
|
|
227
|
+
|
|
228
|
+
// Attach promote handler
|
|
229
|
+
const promoteBtn = result.querySelector('.qa-promote-btn');
|
|
230
|
+
if (promoteBtn) {
|
|
231
|
+
promoteBtn.addEventListener('click', () => promoteToWiki(data));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Replace highlight with a link to the new page
|
|
235
|
+
if (highlightMark && highlightMark.parentNode) {
|
|
236
|
+
const link = document.createElement('a');
|
|
237
|
+
link.href = data.url;
|
|
238
|
+
link.textContent = highlightMark.textContent;
|
|
239
|
+
link.className = 'wiki-link dynamic-link';
|
|
240
|
+
link.title = 'π ' + data.title;
|
|
241
|
+
highlightMark.parentNode.replaceChild(link, highlightMark);
|
|
242
|
+
highlightMark = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function promoteToWiki(data) {
|
|
247
|
+
const promoteBtn = result.querySelector('.qa-promote-btn');
|
|
248
|
+
if (promoteBtn) {
|
|
249
|
+
promoteBtn.disabled = true;
|
|
250
|
+
promoteBtn.textContent = 'β³ μ μ₯ μ€...';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const resp = await fetch('/api/promote', {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: {
|
|
257
|
+
'Content-Type': 'application/json',
|
|
258
|
+
'Authorization': 'Bearer ' + authToken
|
|
259
|
+
},
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
question: data.question || '',
|
|
262
|
+
answer: data.content || '',
|
|
263
|
+
title: data.suggestedTitle || data.title,
|
|
264
|
+
sourcePageId: data.sourcePageId || parseInt(pageId),
|
|
265
|
+
selectedText: data.selectedText || selectedText,
|
|
266
|
+
})
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const promoteData = await resp.json();
|
|
270
|
+
|
|
271
|
+
if (promoteData.ok) {
|
|
272
|
+
if (promoteBtn) {
|
|
273
|
+
promoteBtn.remove();
|
|
274
|
+
}
|
|
275
|
+
const notice = document.createElement('div');
|
|
276
|
+
notice.className = 'qa-promote-success';
|
|
277
|
+
const statusText = promoteData.updated ? 'κΈ°μ‘΄ νμ΄μ§μ λ΄μ© μΆκ°λ¨' : 'μν€ νμ΄μ§λ‘ μ μ₯λ¨ (ν΄μ¦ ν¬ν¨)';
|
|
278
|
+
notice.innerHTML = `<span>β
${esc(statusText)}</span> <a href="${promoteData.url}">β ${esc(promoteData.title)}</a>`;
|
|
279
|
+
result.appendChild(notice);
|
|
280
|
+
|
|
281
|
+
const toastMsg = promoteData.updated ? 'μν€ νμ΄μ§κ° μ
λ°μ΄νΈλμμ΅λλ€' : 'μν€ νμ΄μ§κ° μΆκ°λμμ΅λλ€';
|
|
282
|
+
showToast(toastMsg, 'success', { href: promoteData.url, label: promoteData.title });
|
|
283
|
+
|
|
284
|
+
if (!promoteData.updated && promoteData.slug) {
|
|
285
|
+
injectSidebarEntry(promoteData.slug, promoteData.title);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
if (promoteBtn) {
|
|
289
|
+
promoteBtn.disabled = false;
|
|
290
|
+
promoteBtn.textContent = 'πΎ μν€μ μ μ₯';
|
|
291
|
+
}
|
|
292
|
+
showToast(promoteData.error || 'μ μ₯μ μ€ν¨νμ΅λλ€', 'error');
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
if (promoteBtn) {
|
|
296
|
+
promoteBtn.disabled = false;
|
|
297
|
+
promoteBtn.textContent = 'πΎ μν€μ μ μ₯';
|
|
298
|
+
}
|
|
299
|
+
showToast('μλ² μ°κ²°μ μ€ν¨νμ΅λλ€', 'error');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function showToast(msg, kind, link) {
|
|
304
|
+
const existing = document.querySelector('.kiwi-toast');
|
|
305
|
+
if (existing) existing.remove();
|
|
306
|
+
|
|
307
|
+
const toast = document.createElement('div');
|
|
308
|
+
toast.className = 'kiwi-toast ' + (kind === 'error' ? 'error' : 'success');
|
|
309
|
+
const icon = kind === 'error' ? 'β οΈ' : 'β
';
|
|
310
|
+
let inner = `<span class="kiwi-toast-icon">${icon}</span><span class="kiwi-toast-text">${esc(msg)}</span>`;
|
|
311
|
+
if (link && link.href) {
|
|
312
|
+
inner += ` <a class="kiwi-toast-link" href="${link.href}">β ${esc(link.label || 'λ°λ‘κ°κΈ°')}</a>`;
|
|
313
|
+
}
|
|
314
|
+
toast.innerHTML = inner;
|
|
315
|
+
document.body.appendChild(toast);
|
|
316
|
+
|
|
317
|
+
requestAnimationFrame(() => toast.classList.add('show'));
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
toast.classList.remove('show');
|
|
320
|
+
setTimeout(() => toast.remove(), 400);
|
|
321
|
+
}, 3500);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function injectSidebarEntry(slug, title) {
|
|
325
|
+
const conceptList = document.querySelector('#tab-concept .page-list');
|
|
326
|
+
if (!conceptList) return;
|
|
327
|
+
if (conceptList.querySelector(`a[href="/wiki/${CSS.escape(slug)}.html"]`)) return;
|
|
328
|
+
|
|
329
|
+
const li = document.createElement('li');
|
|
330
|
+
li.className = 'sidebar-new-entry';
|
|
331
|
+
li.innerHTML = `<a href="/wiki/${esc(slug)}.html">π¬ ${esc(title)}</a>`;
|
|
332
|
+
conceptList.prepend(li);
|
|
333
|
+
|
|
334
|
+
const conceptTab = document.querySelector('.sidebar-tab[data-tab="concept"]');
|
|
335
|
+
if (conceptTab) {
|
|
336
|
+
const m = conceptTab.textContent.match(/\((\d+)\)/);
|
|
337
|
+
if (m) {
|
|
338
|
+
conceptTab.textContent = conceptTab.textContent.replace(/\(\d+\)/, `(${parseInt(m[1]) + 1})`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function showError(msg) {
|
|
344
|
+
loading.style.display = 'none';
|
|
345
|
+
isGenerating = false;
|
|
346
|
+
errorDiv.textContent = msg;
|
|
347
|
+
errorDiv.style.display = 'block';
|
|
348
|
+
// Show retry button
|
|
349
|
+
popover.querySelector('.qa-popover-body').style.display = 'flex';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Event: text selection with 500ms delay
|
|
353
|
+
document.addEventListener('mouseup', (e) => {
|
|
354
|
+
if (popover.contains(e.target)) return;
|
|
355
|
+
|
|
356
|
+
clearTimeout(popoverTimer);
|
|
357
|
+
|
|
358
|
+
const selection = window.getSelection();
|
|
359
|
+
const text = selection?.toString().trim();
|
|
360
|
+
|
|
361
|
+
if (!text || text.length < 3) {
|
|
362
|
+
if (!popover.contains(e.target) && result.style.display !== 'block') {
|
|
363
|
+
setTimeout(() => {
|
|
364
|
+
if (!popover.contains(document.activeElement)) hidePopover();
|
|
365
|
+
}, 200);
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Check if selection is within .page-body
|
|
371
|
+
const range = selection.getRangeAt(0);
|
|
372
|
+
const ancestor = range.commonAncestorContainer;
|
|
373
|
+
const pageBody = ancestor.nodeType === 1
|
|
374
|
+
? ancestor.closest('.page-body')
|
|
375
|
+
: ancestor.parentElement?.closest('.page-body');
|
|
376
|
+
if (!pageBody) return;
|
|
377
|
+
|
|
378
|
+
// Delay popover by 500ms to allow editing the selection
|
|
379
|
+
const savedRange = range.cloneRange();
|
|
380
|
+
const savedRect = range.getBoundingClientRect();
|
|
381
|
+
popoverTimer = setTimeout(() => {
|
|
382
|
+
// Verify selection still exists after delay
|
|
383
|
+
const currentSelection = window.getSelection();
|
|
384
|
+
const currentText = currentSelection?.toString().trim();
|
|
385
|
+
if (currentText && currentText.length >= 3) {
|
|
386
|
+
showPopover(currentText, savedRect, savedRange);
|
|
387
|
+
}
|
|
388
|
+
}, 500);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Mobile: use selectionchange with debounce (already has delay)
|
|
392
|
+
let selectionTimer;
|
|
393
|
+
document.addEventListener('selectionchange', () => {
|
|
394
|
+
clearTimeout(selectionTimer);
|
|
395
|
+
selectionTimer = setTimeout(() => {
|
|
396
|
+
const selection = window.getSelection();
|
|
397
|
+
const text = selection?.toString().trim();
|
|
398
|
+
if (!text || text.length < 3) return;
|
|
399
|
+
|
|
400
|
+
const range = selection.getRangeAt(0);
|
|
401
|
+
const ancestor = range.commonAncestorContainer;
|
|
402
|
+
const pageBody = ancestor.nodeType === 1
|
|
403
|
+
? ancestor.closest('.page-body')
|
|
404
|
+
: ancestor.parentElement?.closest('.page-body');
|
|
405
|
+
if (!pageBody) return;
|
|
406
|
+
|
|
407
|
+
showPopover(text, range.getBoundingClientRect(), range.cloneRange());
|
|
408
|
+
}, 800); // Slightly longer delay for mobile
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Event: generate button
|
|
412
|
+
generateBtn.addEventListener('click', generateConcept);
|
|
413
|
+
|
|
414
|
+
// Event: Escape key
|
|
415
|
+
document.addEventListener('keydown', (e) => {
|
|
416
|
+
if (e.key === 'Escape') hidePopover();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Prevent popover clicks from dismissing
|
|
420
|
+
popover.addEventListener('mousedown', (e) => {
|
|
421
|
+
e.stopPropagation();
|
|
422
|
+
});
|
|
423
|
+
})();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
'use strict';
|
|
3
|
+
const token = document.querySelector('meta[name="kiwi-auth"]')?.content;
|
|
4
|
+
if (!token) return; // Static build - editing disabled
|
|
5
|
+
|
|
6
|
+
const slug = document.querySelector('[data-page-slug]')?.dataset.pageSlug;
|
|
7
|
+
if (!slug) return;
|
|
8
|
+
|
|
9
|
+
const editBtn = document.querySelector('.edit-btn');
|
|
10
|
+
const modal = document.getElementById('edit-modal');
|
|
11
|
+
const textarea = document.getElementById('edit-textarea');
|
|
12
|
+
if (!editBtn || !modal || !textarea) return;
|
|
13
|
+
|
|
14
|
+
// Show the edit button only when auth token is present
|
|
15
|
+
editBtn.style.display = 'inline';
|
|
16
|
+
|
|
17
|
+
// Fetch current markdown content
|
|
18
|
+
editBtn.addEventListener('click', async () => {
|
|
19
|
+
try {
|
|
20
|
+
const resp = await fetch('/api/page/' + encodeURIComponent(slug), {
|
|
21
|
+
headers: { 'Authorization': 'Bearer ' + token }
|
|
22
|
+
});
|
|
23
|
+
const data = await resp.json();
|
|
24
|
+
textarea.value = data.content;
|
|
25
|
+
modal.style.display = 'flex';
|
|
26
|
+
} catch (e) {
|
|
27
|
+
alert('νμ΄μ§ λ΄μ©μ λΆλ¬μ¬ μ μμ΅λλ€');
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Close modal
|
|
32
|
+
modal.querySelector('.edit-modal-close')?.addEventListener('click', () => {
|
|
33
|
+
modal.style.display = 'none';
|
|
34
|
+
});
|
|
35
|
+
modal.querySelector('.edit-cancel')?.addEventListener('click', () => {
|
|
36
|
+
modal.style.display = 'none';
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Save
|
|
40
|
+
modal.querySelector('.edit-save')?.addEventListener('click', async () => {
|
|
41
|
+
const content = textarea.value;
|
|
42
|
+
try {
|
|
43
|
+
const resp = await fetch('/api/page/edit', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
46
|
+
body: JSON.stringify({ slug: slug, content: content })
|
|
47
|
+
});
|
|
48
|
+
const data = await resp.json();
|
|
49
|
+
if (data.ok) {
|
|
50
|
+
location.reload(); // Reload to see updated page
|
|
51
|
+
} else {
|
|
52
|
+
alert(data.error || 'μ μ₯μ μ€ν¨νμ΅λλ€');
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
alert('μλ² μ°κ²°μ μ€ν¨νμ΅λλ€');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
})();
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* Notion-style peek panel: right-side slide-in for /wiki/* links.
|
|
2
|
+
All rules are scoped under .peek-* to avoid leaking into the page. */
|
|
3
|
+
|
|
4
|
+
/* z-index: stays above topbar (100) and sidebar (200), below qa-popover (1000)
|
|
5
|
+
and edit-modal (1001) so popovers/modals triggered inside the panel surface
|
|
6
|
+
correctly above it. */
|
|
7
|
+
.peek-backdrop {
|
|
8
|
+
position: fixed;
|
|
9
|
+
inset: 0;
|
|
10
|
+
background: rgba(0, 0, 0, 0.35);
|
|
11
|
+
opacity: 0;
|
|
12
|
+
pointer-events: none;
|
|
13
|
+
transition: opacity 200ms ease;
|
|
14
|
+
z-index: 500;
|
|
15
|
+
}
|
|
16
|
+
.peek-backdrop.is-open {
|
|
17
|
+
opacity: 1;
|
|
18
|
+
pointer-events: auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.peek-panel {
|
|
22
|
+
position: fixed;
|
|
23
|
+
top: 0;
|
|
24
|
+
right: 0;
|
|
25
|
+
bottom: 0;
|
|
26
|
+
width: min(720px, max(480px, 40vw));
|
|
27
|
+
background: var(--bg, #fff);
|
|
28
|
+
color: var(--text, #1a1a1a);
|
|
29
|
+
border-left: 1px solid var(--border, #dcdcdc);
|
|
30
|
+
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
|
|
31
|
+
transform: translateX(100%);
|
|
32
|
+
transition: transform 250ms ease;
|
|
33
|
+
z-index: 501;
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
outline: none;
|
|
37
|
+
}
|
|
38
|
+
.peek-panel.is-open {
|
|
39
|
+
transform: translateX(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Resize handle on the left edge β invisible 8px hit area with a visible
|
|
43
|
+
1px line on hover/active. Disabled on mobile (panel is full-screen). */
|
|
44
|
+
.peek-resize-handle {
|
|
45
|
+
position: absolute;
|
|
46
|
+
top: 0;
|
|
47
|
+
left: -4px;
|
|
48
|
+
width: 8px;
|
|
49
|
+
height: 100%;
|
|
50
|
+
cursor: ew-resize;
|
|
51
|
+
z-index: 1;
|
|
52
|
+
touch-action: none;
|
|
53
|
+
}
|
|
54
|
+
.peek-resize-handle::after {
|
|
55
|
+
content: "";
|
|
56
|
+
position: absolute;
|
|
57
|
+
top: 0;
|
|
58
|
+
left: 3px;
|
|
59
|
+
width: 2px;
|
|
60
|
+
height: 100%;
|
|
61
|
+
background: transparent;
|
|
62
|
+
transition: background 150ms ease;
|
|
63
|
+
}
|
|
64
|
+
.peek-resize-handle:hover::after,
|
|
65
|
+
body.peek-resizing .peek-resize-handle::after {
|
|
66
|
+
background: var(--namu-green, #00a495);
|
|
67
|
+
}
|
|
68
|
+
/* Suspend transition + select while dragging so resize feels immediate. */
|
|
69
|
+
body.peek-resizing {
|
|
70
|
+
cursor: ew-resize;
|
|
71
|
+
user-select: none;
|
|
72
|
+
}
|
|
73
|
+
body.peek-resizing .peek-panel {
|
|
74
|
+
transition: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.peek-header {
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 8px;
|
|
81
|
+
padding: 8px 12px;
|
|
82
|
+
background: var(--bg-alt, #f6f6f6);
|
|
83
|
+
border-bottom: 1px solid var(--border, #dcdcdc);
|
|
84
|
+
flex: 0 0 auto;
|
|
85
|
+
min-height: var(--topbar-height, 44px);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Defeat .page-body h2 (green pill) inheritance for the header title β the
|
|
89
|
+
title isn't article content, so it should stay as plain header text. */
|
|
90
|
+
.peek-panel .peek-title {
|
|
91
|
+
flex: 1 1 auto;
|
|
92
|
+
font-size: 0.95rem;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
margin: 0;
|
|
95
|
+
padding: 0;
|
|
96
|
+
background: transparent;
|
|
97
|
+
color: var(--text, #1a1a1a);
|
|
98
|
+
white-space: nowrap;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
text-overflow: ellipsis;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.peek-actions {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 4px;
|
|
107
|
+
flex: 0 0 auto;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.peek-button {
|
|
111
|
+
background: transparent;
|
|
112
|
+
border: 1px solid transparent;
|
|
113
|
+
color: var(--text, #1a1a1a);
|
|
114
|
+
font: inherit;
|
|
115
|
+
font-size: 0.95rem;
|
|
116
|
+
line-height: 1;
|
|
117
|
+
padding: 6px 10px;
|
|
118
|
+
border-radius: 4px;
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
}
|
|
121
|
+
.peek-button:hover {
|
|
122
|
+
background: var(--bg-hover, #eaeaea);
|
|
123
|
+
}
|
|
124
|
+
.peek-button:focus-visible {
|
|
125
|
+
outline: 2px solid var(--namu-green, #00a495);
|
|
126
|
+
outline-offset: 1px;
|
|
127
|
+
}
|
|
128
|
+
.peek-button[hidden] {
|
|
129
|
+
display: none;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.peek-back {
|
|
133
|
+
font-size: 0.95rem;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.peek-content {
|
|
137
|
+
flex: 1 1 auto;
|
|
138
|
+
overflow-y: auto;
|
|
139
|
+
overflow-x: hidden;
|
|
140
|
+
padding: 20px 28px 40px;
|
|
141
|
+
background: var(--bg, #fff);
|
|
142
|
+
-webkit-overflow-scrolling: touch;
|
|
143
|
+
}
|
|
144
|
+
/* Article inside the panel uses the same .page-body styles as full pages,
|
|
145
|
+
but drops the top margin on the first heading so it hugs the header. */
|
|
146
|
+
.peek-content .peek-article {
|
|
147
|
+
margin: 0;
|
|
148
|
+
}
|
|
149
|
+
.peek-content .page-body > :first-child {
|
|
150
|
+
margin-top: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.peek-loading,
|
|
154
|
+
.peek-error {
|
|
155
|
+
padding: 24px;
|
|
156
|
+
text-align: center;
|
|
157
|
+
color: var(--text-muted, #6b6b6b);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.peek-error {
|
|
161
|
+
color: #c0392b;
|
|
162
|
+
}
|
|
163
|
+
.peek-error .peek-button {
|
|
164
|
+
margin-top: 12px;
|
|
165
|
+
border-color: var(--border, #dcdcdc);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.peek-spinner {
|
|
169
|
+
display: inline-block;
|
|
170
|
+
width: 14px;
|
|
171
|
+
height: 14px;
|
|
172
|
+
border: 2px solid currentColor;
|
|
173
|
+
border-right-color: transparent;
|
|
174
|
+
border-radius: 50%;
|
|
175
|
+
vertical-align: -2px;
|
|
176
|
+
margin-right: 6px;
|
|
177
|
+
animation: peek-spin 0.8s linear infinite;
|
|
178
|
+
}
|
|
179
|
+
@keyframes peek-spin {
|
|
180
|
+
to { transform: rotate(360deg); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@media (max-width: 768px) {
|
|
184
|
+
.peek-panel {
|
|
185
|
+
width: 100vw !important;
|
|
186
|
+
border-left: none;
|
|
187
|
+
}
|
|
188
|
+
.peek-resize-handle {
|
|
189
|
+
display: none;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@media (prefers-reduced-motion: reduce) {
|
|
194
|
+
.peek-panel,
|
|
195
|
+
.peek-backdrop {
|
|
196
|
+
transition: none;
|
|
197
|
+
}
|
|
198
|
+
.peek-spinner {
|
|
199
|
+
animation: none;
|
|
200
|
+
}
|
|
201
|
+
}
|