@hiai-gg/hiai-docs 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.
Files changed (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,284 @@
1
+ <!-- TagCreateDialog.svelte — Create or edit a tag (name + color). -->
2
+ <script lang="ts">
3
+ import { Button } from "@hiai-gg/hiai-ui/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@hiai-gg/hiai-ui/components/ui/dialog";
11
+ import { Input } from "@hiai-gg/hiai-ui/components/ui/input";
12
+ import { Label } from "@hiai-gg/hiai-ui/components/ui/label";
13
+ import { Loader2 } from "lucide-svelte";
14
+ import {
15
+ createTag,
16
+ createTagInputSchema,
17
+ type Tag,
18
+ updateTag,
19
+ updateTagInputSchema,
20
+ } from "$lib/api/tags";
21
+ import * as m from "$lib/paraglide/messages.js";
22
+ import { cn } from "$lib/utils";
23
+
24
+ // Curated palette — common tag colors that read well on light & dark surfaces.
25
+ const PRESET_COLORS = [
26
+ "#ef4444", // red
27
+ "#f97316", // orange
28
+ "#f59e0b", // amber
29
+ "#eab308", // yellow
30
+ "#84cc16", // lime
31
+ "#22c55e", // green
32
+ "#10b981", // emerald
33
+ "#06b6d4", // cyan
34
+ "#3b82f6", // blue
35
+ "#8b5cf6", // violet
36
+ "#ec4899", // pink
37
+ ];
38
+
39
+ // biome-ignore lint/style/noNonNullAssertion: guaranteed by array size
40
+ const DEFAULT_COLOR = PRESET_COLORS[9]!; // violet
41
+
42
+ let {
43
+ open = $bindable(false),
44
+ mode = "create",
45
+ tag = null,
46
+ onCreated,
47
+ onUpdated,
48
+ onClose,
49
+ }: {
50
+ open?: boolean;
51
+ mode?: "create" | "edit";
52
+ tag?: Tag | null;
53
+ onCreated?: (tag: Tag) => void;
54
+ onUpdated?: (tag: Tag) => void;
55
+ onClose?: () => void;
56
+ } = $props();
57
+
58
+ let name = $state("");
59
+ let color = $state(DEFAULT_COLOR);
60
+ let nameError = $state<string | null>(null);
61
+ let submitError = $state<string | null>(null);
62
+ let submitting = $state(false);
63
+
64
+ const isEdit = $derived(mode === "edit");
65
+ const dialogTitle = $derived(isEdit ? m.tags_edit() : m.tags_new());
66
+ const submitLabel = $derived(
67
+ submitting
68
+ ? m.action_loading()
69
+ : isEdit
70
+ ? m.action_save()
71
+ : m.action_create(),
72
+ );
73
+
74
+ function reset() {
75
+ name = "";
76
+ color = DEFAULT_COLOR;
77
+ nameError = null;
78
+ submitError = null;
79
+ submitting = false;
80
+ }
81
+
82
+ function seedFromTag(t: Tag | null) {
83
+ if (!t) {
84
+ reset();
85
+ return;
86
+ }
87
+ name = t.name;
88
+ color = t.color ?? DEFAULT_COLOR;
89
+ nameError = null;
90
+ submitError = null;
91
+ submitting = false;
92
+ }
93
+
94
+ function close() {
95
+ open = false;
96
+ reset();
97
+ onClose?.();
98
+ }
99
+
100
+ function handleOpenChange(next: boolean) {
101
+ if (next) {
102
+ seedFromTag(tag);
103
+ } else {
104
+ reset();
105
+ open = false;
106
+ onClose?.();
107
+ }
108
+ }
109
+
110
+ function handleSubmit(e: Event) {
111
+ e.preventDefault();
112
+ nameError = null;
113
+ submitError = null;
114
+
115
+ const trimmed = name.trim();
116
+ if (trimmed.length === 0) {
117
+ nameError = m.tags_name_placeholder();
118
+ return;
119
+ }
120
+
121
+ if (isEdit && tag) {
122
+ const parsed = updateTagInputSchema.safeParse({
123
+ name: trimmed,
124
+ color,
125
+ });
126
+ if (!parsed.success) {
127
+ const issue = parsed.error.issues[0];
128
+ nameError = issue?.message ?? m.error_generic();
129
+ return;
130
+ }
131
+ void doUpdate(tag.id, parsed.data);
132
+ } else {
133
+ const parsed = createTagInputSchema.safeParse({
134
+ name: trimmed,
135
+ color,
136
+ });
137
+ if (!parsed.success) {
138
+ const issue = parsed.error.issues[0];
139
+ nameError = issue?.message ?? m.error_generic();
140
+ return;
141
+ }
142
+ void doCreate(parsed.data.name, color);
143
+ }
144
+ }
145
+
146
+ async function doCreate(trimmed: string, pickedColor: string) {
147
+ submitting = true;
148
+ try {
149
+ const created = await createTag(trimmed, pickedColor);
150
+ onCreated?.(created);
151
+ close();
152
+ } catch (e) {
153
+ submitError = e instanceof Error ? e.message : m.error_generic();
154
+ console.error("TagCreateDialog: createTag failed", e);
155
+ } finally {
156
+ submitting = false;
157
+ }
158
+ }
159
+
160
+ async function doUpdate(id: string, data: { name?: string; color?: string }) {
161
+ submitting = true;
162
+ try {
163
+ const updated = await updateTag(id, data);
164
+ onUpdated?.(updated);
165
+ close();
166
+ } catch (e) {
167
+ submitError = e instanceof Error ? e.message : m.error_generic();
168
+ console.error("TagCreateDialog: updateTag failed", e);
169
+ } finally {
170
+ submitting = false;
171
+ }
172
+ }
173
+
174
+ function handleInputKeydown(e: KeyboardEvent) {
175
+ if (e.key === "Enter" && !submitting) {
176
+ e.preventDefault();
177
+ handleSubmit(new Event("submit"));
178
+ }
179
+ }
180
+
181
+ $effect(() => {
182
+ if (open && mode === "edit" && tag) {
183
+ name = tag.name;
184
+ color = tag.color ?? DEFAULT_COLOR;
185
+ nameError = null;
186
+ submitError = null;
187
+ }
188
+ });
189
+ </script>
190
+
191
+ <Dialog bind:open onOpenChange={handleOpenChange}>
192
+ <DialogHeader>
193
+ <DialogTitle>{dialogTitle}</DialogTitle>
194
+ <DialogDescription>{m.tags_name_placeholder()}</DialogDescription>
195
+ </DialogHeader>
196
+
197
+ <form onsubmit={handleSubmit} class="space-y-4">
198
+ <div class="space-y-2">
199
+ <Label for="tag-name" class="sr-only">{m.tags_name_placeholder()}</Label>
200
+ <Input
201
+ id="tag-name"
202
+ name="name"
203
+ type="text"
204
+ bind:value={name}
205
+ placeholder={m.tags_name_placeholder()}
206
+ maxlength={50}
207
+ required
208
+ disabled={submitting}
209
+ aria-invalid={nameError ? "true" : undefined}
210
+ aria-describedby={nameError ? "tag-name-error" : undefined}
211
+ autocomplete="off"
212
+ onkeydown={handleInputKeydown}
213
+ />
214
+ {#if nameError}
215
+ <p id="tag-name-error" class="text-xs text-destructive">{nameError}</p>
216
+ {/if}
217
+ </div>
218
+
219
+ <div class="space-y-2">
220
+ <Label for="tag-color" class="text-xs text-muted-foreground">
221
+ Color
222
+ </Label>
223
+ <div id="tag-color" class="flex flex-wrap gap-2">
224
+ {#each PRESET_COLORS as preset (preset)}
225
+ {@const selected = color === preset}
226
+ <button
227
+ type="button"
228
+ class={cn(
229
+ "size-6 shrink-0 rounded-full border-2 transition-all",
230
+ selected
231
+ ? "scale-110 border-foreground"
232
+ : "border-transparent hover:scale-105",
233
+ )}
234
+ style="background-color: {preset};"
235
+ aria-label="Color {preset}"
236
+ aria-pressed={selected}
237
+ disabled={submitting}
238
+ onclick={() => (color = preset)}
239
+ ></button>
240
+ {/each}
241
+ <label
242
+ class={cn(
243
+ "relative inline-flex size-6 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 transition-all",
244
+ !PRESET_COLORS.includes(color)
245
+ ? "scale-110 border-foreground"
246
+ : "border-transparent hover:scale-105",
247
+ )}
248
+ style="background-color: {color};"
249
+ title="Custom color"
250
+ >
251
+ <input
252
+ type="color"
253
+ value={color}
254
+ disabled={submitting}
255
+ oninput={(e) =>
256
+ (color = (e.currentTarget as HTMLInputElement).value)}
257
+ class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
258
+ aria-label="Custom color"
259
+ />
260
+ </label>
261
+ </div>
262
+ </div>
263
+
264
+ {#if submitError}
265
+ <p class="text-xs text-destructive" role="alert">{submitError}</p>
266
+ {/if}
267
+ </form>
268
+
269
+ <DialogFooter>
270
+ <Button variant="outline" type="button" onclick={close} disabled={submitting}>
271
+ {m.action_cancel()}
272
+ </Button>
273
+ <Button
274
+ type="submit"
275
+ onclick={handleSubmit}
276
+ disabled={submitting || name.trim().length === 0}
277
+ >
278
+ {#if submitting}
279
+ <Loader2 class="mr-1 size-4 animate-spin" />
280
+ {/if}
281
+ {submitLabel}
282
+ </Button>
283
+ </DialogFooter>
284
+ </Dialog>
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ interface DiffLine {
3
+ type: "added" | "removed" | "unchanged";
4
+ text: string;
5
+ }
6
+
7
+ const { oldContent, newContent }: { oldContent: string; newContent: string } =
8
+ $props();
9
+
10
+ function computeDiff(oldText: string, newText: string): DiffLine[] {
11
+ const oldLines = oldText.split("\n");
12
+ const newLines = newText.split("\n");
13
+ const result: DiffLine[] = [];
14
+ const maxLen = Math.max(oldLines.length, newLines.length);
15
+
16
+ for (let i = 0; i < maxLen; i++) {
17
+ const oldLine = oldLines[i];
18
+ const newLine = newLines[i];
19
+
20
+ if (oldLine === undefined) {
21
+ if (newLine !== undefined) {
22
+ result.push({ type: "added", text: newLine });
23
+ }
24
+ } else if (newLine === undefined) {
25
+ result.push({ type: "removed", text: oldLine });
26
+ } else if (oldLine === newLine) {
27
+ result.push({ type: "unchanged", text: oldLine });
28
+ } else {
29
+ result.push({ type: "removed", text: oldLine });
30
+ result.push({ type: "added", text: newLine });
31
+ }
32
+ }
33
+
34
+ return result;
35
+ }
36
+
37
+ const diff = $derived(computeDiff(oldContent, newContent));
38
+ </script>
39
+
40
+ <div class="overflow-auto rounded-md border border-border font-mono text-sm">
41
+ {#each diff as line, i (i)}
42
+ <div
43
+ class="px-3 py-0.5 {line.type === 'added'
44
+ ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
45
+ : line.type === 'removed'
46
+ ? 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'
47
+ : 'text-foreground'}"
48
+ >
49
+ <span class="mr-3 inline-block w-4 text-right text-muted-foreground select-none">
50
+ {line.type === "added" ? "+" : line.type === "removed" ? "-" : " "}
51
+ </span>
52
+ {line.text}
53
+ </div>
54
+ {/each}
55
+ </div>
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import { Clock, History, Loader2, RotateCcw } from "lucide-svelte";
3
+ import { onMount } from "svelte";
4
+ import { apiFetch } from "$lib/api/client";
5
+ import * as m from "$lib/paraglide/messages.js";
6
+
7
+ interface Version {
8
+ id: string;
9
+ documentId: string;
10
+ content: string;
11
+ contentJson?: unknown;
12
+ createdBy: string;
13
+ createdAt: string;
14
+ }
15
+
16
+ const { documentId }: { documentId: string } = $props();
17
+
18
+ let versions = $state<Version[]>([]);
19
+ let loading = $state(true);
20
+ let loadError = $state<string | null>(null);
21
+
22
+ onMount(async () => {
23
+ try {
24
+ versions = await apiFetch<Version[]>(
25
+ `/api/documents/${documentId}/versions`,
26
+ );
27
+ } catch (e) {
28
+ loadError = e instanceof Error ? e.message : String(e);
29
+ console.error("Failed to load versions", e);
30
+ } finally {
31
+ loading = false;
32
+ }
33
+ });
34
+
35
+ /** Trim content to a single-line preview (strip markdown + truncate). */
36
+ function previewFromContent(content: string | undefined): string {
37
+ if (!content) return "";
38
+ const stripped = content
39
+ .replace(/```[\s\S]*?```/g, "")
40
+ .replace(/[#*_`>~-]/g, "")
41
+ .replace(/\s+/g, " ")
42
+ .trim();
43
+ return stripped.length > 100 ? `${stripped.slice(0, 100)}…` : stripped;
44
+ }
45
+
46
+ function relativeTime(value: string | Date): string {
47
+ const created = typeof value === "string" ? new Date(value) : value;
48
+ const diff = Date.now() - created.getTime();
49
+ if (Number.isNaN(diff)) return "";
50
+ const mins = Math.floor(diff / 60000);
51
+ if (mins < 1) return m.time_minutes_ago({ count: 0 });
52
+ if (mins < 60) return m.time_minutes_ago({ count: mins });
53
+ const hrs = Math.floor(mins / 60);
54
+ if (hrs < 24) return m.time_hours_ago({ count: hrs });
55
+ return m.time_days_ago({ count: Math.floor(hrs / 24) });
56
+ }
57
+ </script>
58
+
59
+ <div class="flex flex-col gap-2 p-4">
60
+ <div class="flex items-center gap-2 text-sm font-medium text-foreground">
61
+ <History class="h-4 w-4" />
62
+ <span>{m.version_history_title()}</span>
63
+ </div>
64
+
65
+ {#if loading}
66
+ <div class="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
67
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
68
+ <span>{m.action_loading()}</span>
69
+ </div>
70
+ {:else if loadError}
71
+ <p class="py-4 text-center text-xs text-destructive">{loadError}</p>
72
+ {:else if versions.length === 0}
73
+ <p class="py-4 text-center text-xs text-muted-foreground">{m.version_history_empty()}</p>
74
+ {:else}
75
+ <div class="flex flex-col gap-1 overflow-y-auto max-h-80">
76
+ {#each versions as version (version.id)}
77
+ <div class="flex items-start gap-3 rounded-md border border-border p-3 text-sm hover:bg-accent transition-colors">
78
+ <Clock class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
79
+ <div class="flex-1 min-w-0">
80
+ <div class="flex items-center justify-between gap-2">
81
+ <span class="text-xs text-muted-foreground">{relativeTime(version.createdAt)}</span>
82
+ <button
83
+ class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-background hover:text-foreground transition-colors"
84
+ title={m.version_restore()}
85
+ >
86
+ <RotateCcw class="h-3 w-3" />
87
+ {m.version_restore_short()}
88
+ </button>
89
+ </div>
90
+ <p class="mt-1 truncate text-xs text-muted-foreground">{previewFromContent(version.content)}</p>
91
+ </div>
92
+ </div>
93
+ {/each}
94
+ </div>
95
+ {/if}
96
+ </div>
@@ -0,0 +1,87 @@
1
+ <!-- DocumentTitle.svelte — Editable title input with auto-save on blur -->
2
+ <script lang="ts">
3
+ import * as m from "$lib/paraglide/messages.js";
4
+
5
+ const {
6
+ title = "",
7
+ onUpdate = (_title: string) => {},
8
+ }: {
9
+ title?: string;
10
+ onUpdate?: (title: string) => void;
11
+ } = $props();
12
+
13
+ let focused = $state(false);
14
+ let localTitle = $state("");
15
+ $effect(() => {
16
+ if (!focused) localTitle = title ?? "";
17
+ });
18
+
19
+ // Sync external title changes
20
+ $effect(() => {
21
+ if (!focused) {
22
+ localTitle = title;
23
+ }
24
+ });
25
+
26
+ function handleBlur() {
27
+ focused = false;
28
+ if (localTitle !== title) {
29
+ onUpdate(localTitle);
30
+ }
31
+ }
32
+
33
+ function handleKeydown(e: KeyboardEvent) {
34
+ if (e.key === "Enter") {
35
+ e.preventDefault();
36
+ (e.target as HTMLInputElement).blur();
37
+ }
38
+ if (e.key === "Escape") {
39
+ localTitle = title;
40
+ (e.target as HTMLInputElement).blur();
41
+ }
42
+ }
43
+
44
+ function handleFocus() {
45
+ focused = true;
46
+ }
47
+ </script>
48
+
49
+ <input
50
+ type="text"
51
+ class="title-input"
52
+ class:focused
53
+ bind:value={localTitle}
54
+ onfocus={handleFocus}
55
+ onblur={handleBlur}
56
+ onkeydown={handleKeydown}
57
+ placeholder={m.doc_title_placeholder()}
58
+ aria-label={m.doc_title_label()}
59
+ />
60
+
61
+ <style>
62
+ .title-input {
63
+ border: none;
64
+ border-bottom: 2px solid transparent;
65
+ outline: none;
66
+ background: transparent;
67
+ font-size: 2rem;
68
+ font-weight: 700;
69
+ color: var(--foreground);
70
+ width: 100%;
71
+ padding: 0 0 4px 0;
72
+ margin-bottom: 8px;
73
+ transition: border-color 0.15s ease;
74
+ }
75
+
76
+ .title-input::placeholder {
77
+ color: var(--muted-foreground);
78
+ }
79
+
80
+ .title-input.focused {
81
+ border-bottom-color: var(--ring);
82
+ }
83
+
84
+ .title-input:hover:not(.focused) {
85
+ border-bottom-color: var(--border);
86
+ }
87
+ </style>