@farming-labs/svelte-theme 0.0.1
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/package.json +57 -0
- package/src/components/AskAIDialog.svelte +342 -0
- package/src/components/Breadcrumb.svelte +47 -0
- package/src/components/Callout.svelte +28 -0
- package/src/components/DocsContent.svelte +32 -0
- package/src/components/DocsLayout.svelte +258 -0
- package/src/components/DocsPage.svelte +145 -0
- package/src/components/DocsSidebar.svelte +63 -0
- package/src/components/FloatingAIChat.svelte +474 -0
- package/src/components/MobileNav.svelte +14 -0
- package/src/components/SearchDialog.svelte +91 -0
- package/src/components/TableOfContents.svelte +69 -0
- package/src/components/ThemeToggle.svelte +40 -0
- package/src/index.d.ts +23 -0
- package/src/index.js +23 -0
- package/src/lib/renderMarkdown.js +110 -0
- package/src/themes/darksharp.js +42 -0
- package/src/themes/default.js +42 -0
- package/src/themes/pixel-border.js +38 -0
- package/styles/docs.css +2124 -0
- package/styles/pixel-border-bundle.css +6 -0
- package/styles/pixel-border.css +601 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount, tick } from "svelte";
|
|
3
|
+
import { renderMarkdown } from "../lib/renderMarkdown.js";
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
api = "/api/docs",
|
|
7
|
+
suggestedQuestions = [],
|
|
8
|
+
aiLabel = "AI",
|
|
9
|
+
position = "bottom-right",
|
|
10
|
+
floatingStyle = "panel",
|
|
11
|
+
} = $props();
|
|
12
|
+
|
|
13
|
+
let isOpen = $state(false);
|
|
14
|
+
let messages = $state([]);
|
|
15
|
+
let aiInput = $state("");
|
|
16
|
+
let isStreaming = $state(false);
|
|
17
|
+
let mounted = $state(false);
|
|
18
|
+
|
|
19
|
+
onMount(() => { mounted = true; });
|
|
20
|
+
|
|
21
|
+
// ─── Position maps (match Next.js exactly) ─────────────────────
|
|
22
|
+
|
|
23
|
+
const BTN_POSITIONS = {
|
|
24
|
+
"bottom-right": "bottom:24px;right:24px",
|
|
25
|
+
"bottom-left": "bottom:24px;left:24px",
|
|
26
|
+
"bottom-center": "bottom:24px;left:50%;transform:translateX(-50%)",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const PANEL_POSITIONS = {
|
|
30
|
+
"bottom-right": "bottom:80px;right:24px",
|
|
31
|
+
"bottom-left": "bottom:80px;left:24px",
|
|
32
|
+
"bottom-center": "bottom:80px;left:50%;transform:translateX(-50%)",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const POPOVER_POSITIONS = {
|
|
36
|
+
"bottom-right": "bottom:80px;right:24px",
|
|
37
|
+
"bottom-left": "bottom:80px;left:24px",
|
|
38
|
+
"bottom-center": "bottom:80px;left:50%;transform:translateX(-50%)",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function getContainerStyle(style, pos) {
|
|
42
|
+
switch (style) {
|
|
43
|
+
case "modal":
|
|
44
|
+
return "top:50%;left:50%;transform:translate(-50%,-50%);width:min(680px,calc(100vw - 32px));height:min(560px,calc(100vh - 64px))";
|
|
45
|
+
case "popover":
|
|
46
|
+
return `${POPOVER_POSITIONS[pos] || POPOVER_POSITIONS["bottom-right"]};width:min(360px,calc(100vw - 48px));height:min(400px,calc(100vh - 120px))`;
|
|
47
|
+
case "panel":
|
|
48
|
+
default:
|
|
49
|
+
return `${PANEL_POSITIONS[pos] || PANEL_POSITIONS["bottom-right"]};width:min(400px,calc(100vw - 48px));height:min(500px,calc(100vh - 120px))`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getAnimation(style) {
|
|
54
|
+
return style === "modal"
|
|
55
|
+
? "fd-ai-float-center-in 200ms ease-out"
|
|
56
|
+
: "fd-ai-float-in 200ms ease-out";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Shared logic ──────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function handleKeydown(e) {
|
|
62
|
+
if (e.key === "Escape" && isOpen) {
|
|
63
|
+
isOpen = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (isOpen && (floatingStyle === "modal" || floatingStyle === "full-modal")) {
|
|
69
|
+
document.body.style.overflow = "hidden";
|
|
70
|
+
} else {
|
|
71
|
+
document.body.style.overflow = "";
|
|
72
|
+
}
|
|
73
|
+
return () => { document.body.style.overflow = ""; };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function submitQuestion(question) {
|
|
77
|
+
if (!question.trim() || isStreaming) return;
|
|
78
|
+
const userMsg = { role: "user", content: question };
|
|
79
|
+
const newMessages = [...messages, userMsg];
|
|
80
|
+
aiInput = "";
|
|
81
|
+
isStreaming = true;
|
|
82
|
+
messages = [...newMessages, { role: "assistant", content: "" }];
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(api, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
messages: newMessages.map((m) => ({ role: m.role, content: m.content })),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
let errMsg = "Something went wrong.";
|
|
95
|
+
try {
|
|
96
|
+
const err = await res.json();
|
|
97
|
+
errMsg = err.error || errMsg;
|
|
98
|
+
} catch {}
|
|
99
|
+
messages = [...newMessages, { role: "assistant", content: errMsg }];
|
|
100
|
+
isStreaming = false;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reader = res.body.getReader();
|
|
105
|
+
const decoder = new TextDecoder();
|
|
106
|
+
let buffer = "";
|
|
107
|
+
let assistantContent = "";
|
|
108
|
+
|
|
109
|
+
while (true) {
|
|
110
|
+
const { done, value } = await reader.read();
|
|
111
|
+
if (done) break;
|
|
112
|
+
buffer += decoder.decode(value, { stream: true });
|
|
113
|
+
const lines = buffer.split("\n");
|
|
114
|
+
buffer = lines.pop() || "";
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (line.startsWith("data: ")) {
|
|
117
|
+
const data = line.slice(6).trim();
|
|
118
|
+
if (data === "[DONE]") continue;
|
|
119
|
+
try {
|
|
120
|
+
const json = JSON.parse(data);
|
|
121
|
+
const content = json.choices?.[0]?.delta?.content;
|
|
122
|
+
if (content) {
|
|
123
|
+
assistantContent += content;
|
|
124
|
+
messages = [...newMessages, { role: "assistant", content: assistantContent }];
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (assistantContent) {
|
|
131
|
+
messages = [...newMessages, { role: "assistant", content: assistantContent }];
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
messages = [
|
|
135
|
+
...newMessages,
|
|
136
|
+
{ role: "assistant", content: "Failed to connect. Please try again." },
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
isStreaming = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clearChat() {
|
|
143
|
+
if (!isStreaming) {
|
|
144
|
+
messages = [];
|
|
145
|
+
aiInput = "";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let label = $derived(aiLabel || "AI");
|
|
150
|
+
let canSend = $derived(!!(aiInput.trim() && !isStreaming));
|
|
151
|
+
let isFullModal = $derived(floatingStyle === "full-modal");
|
|
152
|
+
let isModal = $derived(floatingStyle === "modal");
|
|
153
|
+
let btnStyle = $derived(BTN_POSITIONS[position] || BTN_POSITIONS["bottom-right"]);
|
|
154
|
+
let containerStyle = $derived(getContainerStyle(floatingStyle, position));
|
|
155
|
+
let animation = $derived(getAnimation(floatingStyle));
|
|
156
|
+
let showSuggestions = $derived(messages.length === 0 && !isStreaming);
|
|
157
|
+
|
|
158
|
+
// ─── Full-modal refs ───────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
let fmInputEl = $state(null);
|
|
161
|
+
let fmListEl = $state(null);
|
|
162
|
+
|
|
163
|
+
// ─── Panel/modal/popover refs ──────────────────────────────────
|
|
164
|
+
|
|
165
|
+
let aiInputEl = $state(null);
|
|
166
|
+
let messagesEndEl = $state(null);
|
|
167
|
+
|
|
168
|
+
$effect(() => {
|
|
169
|
+
if (isOpen && isFullModal) {
|
|
170
|
+
setTimeout(() => fmInputEl?.focus(), 100);
|
|
171
|
+
} else if (isOpen && !isFullModal) {
|
|
172
|
+
setTimeout(() => aiInputEl?.focus(), 100);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
$effect(() => {
|
|
177
|
+
if (isFullModal && fmListEl && messages.length > 0) {
|
|
178
|
+
tick().then(() => {
|
|
179
|
+
fmListEl?.scrollTo({ top: fmListEl.scrollHeight, behavior: "smooth" });
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
$effect(() => {
|
|
185
|
+
if (!isFullModal && messages.length > 0) {
|
|
186
|
+
tick().then(() => messagesEndEl?.scrollIntoView({ behavior: "smooth" }));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function handleFmKeyDown(e) {
|
|
191
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
if (canSend) submitQuestion(aiInput);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function handleInputKeydown(e) {
|
|
198
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
submitQuestion(aiInput);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<svelte:window onkeydown={handleKeydown} />
|
|
206
|
+
|
|
207
|
+
{#if mounted}
|
|
208
|
+
{#if isFullModal}
|
|
209
|
+
<!-- ═══ Full-Modal Mode (better-auth inspired) ═══ -->
|
|
210
|
+
|
|
211
|
+
{#if isOpen}
|
|
212
|
+
<!-- Full-screen overlay -->
|
|
213
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
214
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
215
|
+
<div class="fd-ai-fm-overlay" onclick={(e) => { if (e.target === e.currentTarget) isOpen = false; }}>
|
|
216
|
+
<!-- Close button -->
|
|
217
|
+
<div class="fd-ai-fm-topbar">
|
|
218
|
+
<button onclick={() => isOpen = false} class="fd-ai-fm-close-btn" aria-label="Close">
|
|
219
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
220
|
+
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
|
|
221
|
+
</svg>
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<!-- Scrollable message list -->
|
|
226
|
+
<div bind:this={fmListEl} class="fd-ai-fm-messages">
|
|
227
|
+
<div class="fd-ai-fm-messages-inner">
|
|
228
|
+
{#each messages as msg}
|
|
229
|
+
<div class="fd-ai-fm-msg" data-role={msg.role}>
|
|
230
|
+
<div class="fd-ai-fm-msg-label" data-role={msg.role}>
|
|
231
|
+
{msg.role === "user" ? "you" : label}
|
|
232
|
+
</div>
|
|
233
|
+
<div class="fd-ai-fm-msg-content">
|
|
234
|
+
{#if msg.content}
|
|
235
|
+
{@html renderMarkdown(msg.content)}
|
|
236
|
+
{:else}
|
|
237
|
+
<div class="fd-ai-fm-thinking">
|
|
238
|
+
<span class="fd-ai-fm-thinking-dot"></span>
|
|
239
|
+
<span class="fd-ai-fm-thinking-dot"></span>
|
|
240
|
+
<span class="fd-ai-fm-thinking-dot"></span>
|
|
241
|
+
</div>
|
|
242
|
+
{/if}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
{/each}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
{/if}
|
|
250
|
+
|
|
251
|
+
<!-- Bottom input bar — always visible when open, pinned to bottom -->
|
|
252
|
+
<div
|
|
253
|
+
class="fd-ai-fm-input-bar {isOpen ? 'fd-ai-fm-input-bar--open' : 'fd-ai-fm-input-bar--closed'}"
|
|
254
|
+
style={isOpen ? undefined : btnStyle}
|
|
255
|
+
>
|
|
256
|
+
{#if !isOpen}
|
|
257
|
+
<button
|
|
258
|
+
onclick={() => isOpen = true}
|
|
259
|
+
class="fd-ai-fm-trigger-btn"
|
|
260
|
+
aria-label="Ask {label}"
|
|
261
|
+
>
|
|
262
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
263
|
+
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
|
|
264
|
+
<path d="M20 3v4"/><path d="M22 5h-4"/>
|
|
265
|
+
</svg>
|
|
266
|
+
<span>Ask {label}</span>
|
|
267
|
+
</button>
|
|
268
|
+
{:else}
|
|
269
|
+
<div class="fd-ai-fm-input-container">
|
|
270
|
+
<div class="fd-ai-fm-input-wrap">
|
|
271
|
+
<textarea
|
|
272
|
+
bind:this={fmInputEl}
|
|
273
|
+
class="fd-ai-fm-input"
|
|
274
|
+
placeholder={isStreaming ? "answering..." : `Ask ${label}`}
|
|
275
|
+
bind:value={aiInput}
|
|
276
|
+
onkeydown={handleFmKeyDown}
|
|
277
|
+
disabled={isStreaming}
|
|
278
|
+
rows="1"
|
|
279
|
+
></textarea>
|
|
280
|
+
{#if isStreaming}
|
|
281
|
+
<button class="fd-ai-fm-send-btn" onclick={() => isStreaming = false} aria-label="Stop">
|
|
282
|
+
<span class="fd-ai-loading-dots">
|
|
283
|
+
<span class="fd-ai-loading-dot"></span>
|
|
284
|
+
<span class="fd-ai-loading-dot"></span>
|
|
285
|
+
<span class="fd-ai-loading-dot"></span>
|
|
286
|
+
</span>
|
|
287
|
+
</button>
|
|
288
|
+
{:else}
|
|
289
|
+
<button
|
|
290
|
+
class="fd-ai-fm-send-btn"
|
|
291
|
+
data-active={canSend}
|
|
292
|
+
disabled={!canSend}
|
|
293
|
+
onclick={() => submitQuestion(aiInput)}
|
|
294
|
+
aria-label="Send"
|
|
295
|
+
>
|
|
296
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
297
|
+
<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>
|
|
298
|
+
</svg>
|
|
299
|
+
</button>
|
|
300
|
+
{/if}
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
{#if showSuggestions && suggestedQuestions && suggestedQuestions.length > 0}
|
|
304
|
+
<div class="fd-ai-fm-suggestions-area">
|
|
305
|
+
<div class="fd-ai-fm-suggestions-label">Try asking:</div>
|
|
306
|
+
<div class="fd-ai-fm-suggestions">
|
|
307
|
+
{#each suggestedQuestions as q}
|
|
308
|
+
<button onclick={() => submitQuestion(q)} class="fd-ai-fm-suggestion">
|
|
309
|
+
{q}
|
|
310
|
+
</button>
|
|
311
|
+
{/each}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
{/if}
|
|
315
|
+
|
|
316
|
+
<div class="fd-ai-fm-footer-bar">
|
|
317
|
+
{#if messages.length > 0}
|
|
318
|
+
<button
|
|
319
|
+
class="fd-ai-fm-clear-btn"
|
|
320
|
+
onclick={clearChat}
|
|
321
|
+
aria-disabled={isStreaming}
|
|
322
|
+
>
|
|
323
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
324
|
+
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
|
325
|
+
</svg>
|
|
326
|
+
<span>Clear</span>
|
|
327
|
+
</button>
|
|
328
|
+
{:else}
|
|
329
|
+
<div class="fd-ai-fm-footer-hint">
|
|
330
|
+
AI can be inaccurate, please verify the information.
|
|
331
|
+
</div>
|
|
332
|
+
{/if}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
{/if}
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{:else}
|
|
339
|
+
<!-- ═══ Panel / Modal / Popover Mode ═══ -->
|
|
340
|
+
|
|
341
|
+
{#if isOpen && isModal}
|
|
342
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
343
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
344
|
+
<div class="fd-ai-overlay" onclick={() => isOpen = false}></div>
|
|
345
|
+
{/if}
|
|
346
|
+
|
|
347
|
+
{#if isOpen}
|
|
348
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
349
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
350
|
+
<div
|
|
351
|
+
class="fd-ai-dialog"
|
|
352
|
+
onclick={(e) => e.stopPropagation()}
|
|
353
|
+
style="{containerStyle};animation:{animation}"
|
|
354
|
+
>
|
|
355
|
+
<div class="fd-ai-header">
|
|
356
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
357
|
+
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
|
|
358
|
+
</svg>
|
|
359
|
+
<span class="fd-ai-header-title">Ask {label}</span>
|
|
360
|
+
{#if isModal}
|
|
361
|
+
<kbd class="fd-ai-esc">ESC</kbd>
|
|
362
|
+
{/if}
|
|
363
|
+
<button onclick={() => isOpen = false} class="fd-ai-close-btn" aria-label="Close">
|
|
364
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
365
|
+
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
|
|
366
|
+
</svg>
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<!-- AIChat equivalent -->
|
|
371
|
+
<div style="display:flex;flex-direction:column;flex:1;min-height:0">
|
|
372
|
+
<div class="fd-ai-messages">
|
|
373
|
+
{#if messages.length === 0}
|
|
374
|
+
<div class="fd-ai-empty">
|
|
375
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
376
|
+
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
|
|
377
|
+
<path d="M20 3v4"/><path d="M22 5h-4"/>
|
|
378
|
+
</svg>
|
|
379
|
+
<div class="fd-ai-empty-title">Ask anything about the docs</div>
|
|
380
|
+
<div class="fd-ai-empty-desc">
|
|
381
|
+
{label} will search through the documentation and answer your question with relevant context.
|
|
382
|
+
</div>
|
|
383
|
+
{#if suggestedQuestions && suggestedQuestions.length > 0}
|
|
384
|
+
<div class="fd-ai-suggestions">
|
|
385
|
+
{#each suggestedQuestions as q}
|
|
386
|
+
<button onclick={() => submitQuestion(q)} class="fd-ai-suggestion">
|
|
387
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
388
|
+
<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>
|
|
389
|
+
</svg>
|
|
390
|
+
<span style="flex:1">{q}</span>
|
|
391
|
+
</button>
|
|
392
|
+
{/each}
|
|
393
|
+
</div>
|
|
394
|
+
{/if}
|
|
395
|
+
</div>
|
|
396
|
+
{:else}
|
|
397
|
+
{#each messages as msg}
|
|
398
|
+
<div class="fd-ai-msg" data-role={msg.role}>
|
|
399
|
+
<div class="fd-ai-msg-label">
|
|
400
|
+
{msg.role === "user" ? "You" : label}
|
|
401
|
+
</div>
|
|
402
|
+
{#if msg.role === "user"}
|
|
403
|
+
<div class="fd-ai-bubble-user">{msg.content}</div>
|
|
404
|
+
{:else}
|
|
405
|
+
<div class="fd-ai-bubble-ai">
|
|
406
|
+
{#if msg.content}
|
|
407
|
+
{@html renderMarkdown(msg.content)}
|
|
408
|
+
{:else}
|
|
409
|
+
<span class="fd-ai-loading">
|
|
410
|
+
<span class="fd-ai-loading-text">{label} is thinking</span>
|
|
411
|
+
<span class="fd-ai-loading-dots">
|
|
412
|
+
<span class="fd-ai-loading-dot"></span>
|
|
413
|
+
<span class="fd-ai-loading-dot"></span>
|
|
414
|
+
<span class="fd-ai-loading-dot"></span>
|
|
415
|
+
</span>
|
|
416
|
+
</span>
|
|
417
|
+
{/if}
|
|
418
|
+
</div>
|
|
419
|
+
{/if}
|
|
420
|
+
</div>
|
|
421
|
+
{/each}
|
|
422
|
+
<div bind:this={messagesEndEl}></div>
|
|
423
|
+
{/if}
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div class="fd-ai-chat-footer">
|
|
427
|
+
{#if messages.length > 0}
|
|
428
|
+
<div style="display:flex;justify-content:flex-end;padding-bottom:8px">
|
|
429
|
+
<button onclick={clearChat} class="fd-ai-clear-btn">Clear chat</button>
|
|
430
|
+
</div>
|
|
431
|
+
{/if}
|
|
432
|
+
<div class="fd-ai-input-wrap">
|
|
433
|
+
<input
|
|
434
|
+
bind:this={aiInputEl}
|
|
435
|
+
bind:value={aiInput}
|
|
436
|
+
type="text"
|
|
437
|
+
placeholder="Ask a question..."
|
|
438
|
+
onkeydown={handleInputKeydown}
|
|
439
|
+
disabled={isStreaming}
|
|
440
|
+
class="fd-ai-input"
|
|
441
|
+
style="opacity:{isStreaming ? 0.5 : 1}"
|
|
442
|
+
/>
|
|
443
|
+
<button
|
|
444
|
+
onclick={() => submitQuestion(aiInput)}
|
|
445
|
+
disabled={!canSend}
|
|
446
|
+
class="fd-ai-send-btn"
|
|
447
|
+
data-active={canSend}
|
|
448
|
+
aria-label="Send"
|
|
449
|
+
>
|
|
450
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
451
|
+
<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>
|
|
452
|
+
</svg>
|
|
453
|
+
</button>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
{/if}
|
|
459
|
+
|
|
460
|
+
{#if !isOpen}
|
|
461
|
+
<button
|
|
462
|
+
onclick={() => isOpen = true}
|
|
463
|
+
aria-label="Ask {label}"
|
|
464
|
+
class="fd-ai-floating-btn"
|
|
465
|
+
style={btnStyle}
|
|
466
|
+
>
|
|
467
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
468
|
+
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
|
|
469
|
+
<path d="M20 3v4"/><path d="M22 5h-4"/>
|
|
470
|
+
</svg>
|
|
471
|
+
</button>
|
|
472
|
+
{/if}
|
|
473
|
+
{/if}
|
|
474
|
+
{/if}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* MobileNav — Hamburger menu trigger for mobile sidebar.
|
|
4
|
+
*/
|
|
5
|
+
let { onclick } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<button class="fd-mobile-nav-btn" {onclick} aria-label="Open menu">
|
|
9
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
10
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
11
|
+
<line x1="3" y1="12" x2="21" y2="12" />
|
|
12
|
+
<line x1="3" y1="18" x2="21" y2="18" />
|
|
13
|
+
</svg>
|
|
14
|
+
</button>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* SearchDialog — Cmd+K search overlay.
|
|
4
|
+
* Fetches from /api/docs?query=… (same unified handler as Next.js version).
|
|
5
|
+
*/
|
|
6
|
+
import { onMount } from "svelte";
|
|
7
|
+
import { goto } from "$app/navigation";
|
|
8
|
+
|
|
9
|
+
let { onclose } = $props();
|
|
10
|
+
let query = $state("");
|
|
11
|
+
let results = $state([]);
|
|
12
|
+
let loading = $state(false);
|
|
13
|
+
let inputEl;
|
|
14
|
+
let debounceTimer;
|
|
15
|
+
|
|
16
|
+
onMount(() => {
|
|
17
|
+
inputEl?.focus();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
$effect(() => {
|
|
21
|
+
clearTimeout(debounceTimer);
|
|
22
|
+
if (!query.trim()) {
|
|
23
|
+
results = [];
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
loading = true;
|
|
27
|
+
debounceTimer = setTimeout(async () => {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`/api/docs?query=${encodeURIComponent(query)}`);
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
results = data ?? [];
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
results = [];
|
|
36
|
+
} finally {
|
|
37
|
+
loading = false;
|
|
38
|
+
}
|
|
39
|
+
}, 200);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function navigate(url) {
|
|
43
|
+
onclose?.();
|
|
44
|
+
goto(url);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function handleKeydown(e) {
|
|
48
|
+
if (e.key === "Escape") {
|
|
49
|
+
onclose?.();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
55
|
+
<div class="fd-search-overlay" onclick={onclose} onkeydown={handleKeydown} role="dialog">
|
|
56
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
57
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
58
|
+
<div class="fd-search-dialog" onclick={(e) => e.stopPropagation()} role="document">
|
|
59
|
+
<div class="fd-search-input-wrap">
|
|
60
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
61
|
+
<circle cx="11" cy="11" r="8" />
|
|
62
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
63
|
+
</svg>
|
|
64
|
+
<input
|
|
65
|
+
bind:this={inputEl}
|
|
66
|
+
bind:value={query}
|
|
67
|
+
class="fd-search-input"
|
|
68
|
+
placeholder="Search documentation..."
|
|
69
|
+
type="text"
|
|
70
|
+
/>
|
|
71
|
+
<kbd class="fd-search-kbd">ESC</kbd>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="fd-search-results">
|
|
75
|
+
{#if loading}
|
|
76
|
+
<div class="fd-search-empty">Searching...</div>
|
|
77
|
+
{:else if query && results.length === 0}
|
|
78
|
+
<div class="fd-search-empty">No results found for "{query}"</div>
|
|
79
|
+
{:else}
|
|
80
|
+
{#each results as result}
|
|
81
|
+
<button class="fd-search-result" onclick={() => navigate(result.url)}>
|
|
82
|
+
<span class="fd-search-result-title">{result.content}</span>
|
|
83
|
+
{#if result.url}
|
|
84
|
+
<span class="fd-search-result-url">{result.url}</span>
|
|
85
|
+
{/if}
|
|
86
|
+
</button>
|
|
87
|
+
{/each}
|
|
88
|
+
{/if}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount, onDestroy } from "svelte";
|
|
3
|
+
|
|
4
|
+
let { items = [] } = $props();
|
|
5
|
+
let activeId = $state("");
|
|
6
|
+
let observer;
|
|
7
|
+
|
|
8
|
+
onMount(() => {
|
|
9
|
+
observer = new IntersectionObserver(
|
|
10
|
+
(entries) => {
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
if (entry.isIntersecting) {
|
|
13
|
+
activeId = entry.target.id;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{ rootMargin: "-80px 0px -80% 0px" }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
observeHeadings();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
$effect(() => {
|
|
24
|
+
void items;
|
|
25
|
+
observeHeadings();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function observeHeadings() {
|
|
29
|
+
if (!observer) return;
|
|
30
|
+
observer.disconnect();
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
const el = document.querySelector(item.url);
|
|
33
|
+
if (el) observer.observe(el);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
onDestroy(() => {
|
|
38
|
+
observer?.disconnect();
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<div class="fd-toc-inner">
|
|
43
|
+
<h3 class="fd-toc-title">
|
|
44
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
45
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
46
|
+
<line x1="3" y1="12" x2="15" y2="12" />
|
|
47
|
+
<line x1="3" y1="18" x2="18" y2="18" />
|
|
48
|
+
</svg>
|
|
49
|
+
On this page
|
|
50
|
+
</h3>
|
|
51
|
+
{#if items.length === 0}
|
|
52
|
+
<p class="fd-toc-empty">No Headings</p>
|
|
53
|
+
{:else}
|
|
54
|
+
<ul class="fd-toc-list">
|
|
55
|
+
{#each items as item}
|
|
56
|
+
<li class="fd-toc-item">
|
|
57
|
+
<a
|
|
58
|
+
href={item.url}
|
|
59
|
+
class="fd-toc-link"
|
|
60
|
+
class:fd-toc-link-active={activeId === item.url.slice(1)}
|
|
61
|
+
style:padding-left="{12 + (item.depth - 2) * 12}px"
|
|
62
|
+
>
|
|
63
|
+
{item.title}
|
|
64
|
+
</a>
|
|
65
|
+
</li>
|
|
66
|
+
{/each}
|
|
67
|
+
</ul>
|
|
68
|
+
{/if}
|
|
69
|
+
</div>
|