@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,271 @@
1
+ <script lang="ts">
2
+ import { Button } from "@hiai-gg/hiai-ui/components/ui/button";
3
+ import * as Dialog from "@hiai-gg/hiai-ui/components/ui/dialog";
4
+ import { Input } from "@hiai-gg/hiai-ui/components/ui/input";
5
+ import { Label } from "@hiai-gg/hiai-ui/components/ui/label";
6
+ import * as Tabs from "@hiai-gg/hiai-ui/components/ui/tabs";
7
+ import { Loader2, LogOut, Save } from "lucide-svelte";
8
+ import { onMount } from "svelte";
9
+ import { goto } from "$app/navigation";
10
+ import { getProfile, updateProfile } from "$lib/api/settings";
11
+ import { authClient, signOut } from "$lib/auth-client";
12
+ import * as m from "$lib/paraglide/messages.js";
13
+ import { type Theme, themeStore } from "$lib/stores/theme.svelte";
14
+
15
+ let {
16
+ open = $bindable(false),
17
+ }: {
18
+ open?: boolean;
19
+ } = $props();
20
+
21
+ let activeTab = $state("profile");
22
+
23
+ // Profile
24
+ let name = $state("");
25
+ let email = $state("");
26
+ let profileStatus = $state<"idle" | "saving" | "saved" | "error">("idle");
27
+ let profileError = $state("");
28
+
29
+ // Password
30
+ let currentPassword = $state("");
31
+ let newPassword = $state("");
32
+ let confirmPassword = $state("");
33
+ let passwordStatus = $state<"idle" | "saving" | "saved" | "error">("idle");
34
+ let passwordError = $state("");
35
+
36
+ onMount(async () => {
37
+ try {
38
+ const profile = await getProfile();
39
+ if (profile.name) name = profile.name;
40
+ if (profile.email) email = profile.email;
41
+ } catch {
42
+ // use defaults
43
+ }
44
+ });
45
+
46
+ async function saveProfile() {
47
+ profileStatus = "saving";
48
+ profileError = "";
49
+ try {
50
+ await updateProfile({ name });
51
+ profileStatus = "saved";
52
+ setTimeout(() => {
53
+ profileStatus = "idle";
54
+ }, 2000);
55
+ } catch (e) {
56
+ profileStatus = "error";
57
+ profileError = e instanceof Error ? e.message : m.error_document_save();
58
+ }
59
+ }
60
+
61
+ async function changePassword() {
62
+ passwordError = "";
63
+ if (newPassword !== confirmPassword) {
64
+ passwordStatus = "error";
65
+ passwordError = m.auth_password_mismatch();
66
+ return;
67
+ }
68
+ if (newPassword.length < 8) {
69
+ passwordStatus = "error";
70
+ passwordError = m.auth_password_min();
71
+ return;
72
+ }
73
+ passwordStatus = "saving";
74
+ try {
75
+ // Better Auth exposes a dedicated change-password endpoint
76
+ // (POST /api/auth/change-password) that requires `currentPassword` and
77
+ // `newPassword`. The previous `update-user` route is for profile fields
78
+ // (name/email) and rejected this body with 400. revokeOtherSessions
79
+ // invalidates any other active sessions for this user.
80
+ const { error } = await authClient.changePassword({
81
+ currentPassword,
82
+ newPassword,
83
+ revokeOtherSessions: true,
84
+ });
85
+ if (error) {
86
+ passwordStatus = "error";
87
+ passwordError = error.message ?? m.error_generic();
88
+ return;
89
+ }
90
+ passwordStatus = "saved";
91
+ currentPassword = "";
92
+ newPassword = "";
93
+ confirmPassword = "";
94
+ setTimeout(() => {
95
+ passwordStatus = "idle";
96
+ }, 2000);
97
+ } catch (e) {
98
+ passwordStatus = "error";
99
+ passwordError = e instanceof Error ? e.message : m.error_generic();
100
+ }
101
+ }
102
+
103
+ function pickTheme(value: Theme) {
104
+ themeStore.set(value);
105
+ }
106
+
107
+ const themeOptions: Array<{ value: Theme; label: string; key: string }> = [
108
+ { value: "light", label: m.theme_light(), key: "light" },
109
+ { value: "dark", label: m.theme_dark(), key: "dark" },
110
+ { value: "system", label: m.theme_system(), key: "system" },
111
+ ];
112
+
113
+ let loggingOut = $state(false);
114
+
115
+ async function handleLogout() {
116
+ loggingOut = true;
117
+ try {
118
+ await signOut();
119
+ open = false;
120
+ goto("/login");
121
+ } catch {
122
+ loggingOut = false;
123
+ }
124
+ }
125
+
126
+ function close() {
127
+ open = false;
128
+ }
129
+ </script>
130
+
131
+ <Dialog.Dialog bind:open>
132
+ <Dialog.DialogHeader>
133
+ <Dialog.DialogTitle>{m.settings_title()}</Dialog.DialogTitle>
134
+ <Dialog.DialogDescription>
135
+ {m.settings_account()}
136
+ </Dialog.DialogDescription>
137
+ </Dialog.DialogHeader>
138
+
139
+ <Tabs.Tabs bind:value={activeTab} class="w-full">
140
+ <Tabs.TabsList class="grid w-full grid-cols-3">
141
+ <Tabs.TabsTrigger
142
+ value="profile"
143
+ selected={activeTab === "profile"}
144
+ onclick={(v) => (activeTab = v)}
145
+ >
146
+ {m.settings_profile()}
147
+ </Tabs.TabsTrigger>
148
+ <Tabs.TabsTrigger
149
+ value="password"
150
+ selected={activeTab === "password"}
151
+ onclick={(v) => (activeTab = v)}
152
+ >
153
+ {m.password_label()}
154
+ </Tabs.TabsTrigger>
155
+ <Tabs.TabsTrigger
156
+ value="appearance"
157
+ selected={activeTab === "appearance"}
158
+ onclick={(v) => (activeTab = v)}
159
+ >
160
+ {m.settings_appearance()}
161
+ </Tabs.TabsTrigger>
162
+ </Tabs.TabsList>
163
+
164
+ <Tabs.TabsContent value="profile" currentValue={activeTab}>
165
+ <form onsubmit={(e) => { e.preventDefault(); saveProfile(); }} class="space-y-4">
166
+ <div class="space-y-2">
167
+ <Label for="settings-name">{m.settings_name()}</Label>
168
+ <Input id="settings-name" type="text" name="name" bind:value={name} autocomplete="name" />
169
+ </div>
170
+ <div class="space-y-2">
171
+ <Label for="settings-email">{m.settings_email()}</Label>
172
+ <Input id="settings-email" type="email" name="email" bind:value={email} autocomplete="email" disabled />
173
+ </div>
174
+ {#if profileError}
175
+ <p class="text-sm text-destructive">{profileError}</p>
176
+ {/if}
177
+ <Button type="submit" disabled={profileStatus === "saving"}>
178
+ {#if profileStatus === "saving"}
179
+ <Loader2 class="mr-2 size-4 animate-spin" />
180
+ {:else}
181
+ <Save class="mr-2 size-4" />
182
+ {/if}
183
+ {profileStatus === "saved" ? m.settings_saved_status() : m.settings_save()}
184
+ </Button>
185
+ </form>
186
+ </Tabs.TabsContent>
187
+
188
+ <Tabs.TabsContent value="password" currentValue={activeTab}>
189
+ <form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-4">
190
+ <div class="space-y-2">
191
+ <Label for="settings-current-password">{m.auth_password()}</Label>
192
+ <Input
193
+ id="settings-current-password"
194
+ type="password"
195
+ name="current-password"
196
+ bind:value={currentPassword}
197
+ autocomplete="current-password"
198
+ />
199
+ </div>
200
+ <div class="space-y-2">
201
+ <Label for="settings-new-password">{m.new_password()}</Label>
202
+ <Input
203
+ id="settings-new-password"
204
+ type="password"
205
+ name="new-password"
206
+ bind:value={newPassword}
207
+ autocomplete="new-password"
208
+ />
209
+ </div>
210
+ <div class="space-y-2">
211
+ <Label for="settings-confirm-password">{m.confirm_password()}</Label>
212
+ <Input
213
+ id="settings-confirm-password"
214
+ type="password"
215
+ name="confirm-password"
216
+ bind:value={confirmPassword}
217
+ autocomplete="new-password"
218
+ />
219
+ </div>
220
+ {#if passwordError}
221
+ <p class="text-sm text-destructive">{passwordError}</p>
222
+ {/if}
223
+ <Button type="submit" disabled={passwordStatus === "saving"}>
224
+ {#if passwordStatus === "saving"}
225
+ <Loader2 class="mr-2 size-4 animate-spin" />
226
+ {/if}
227
+ {passwordStatus === "saved" ? m.settings_saved_status() : m.change_password()}
228
+ </Button>
229
+ </form>
230
+ </Tabs.TabsContent>
231
+
232
+ <Tabs.TabsContent value="appearance" currentValue={activeTab}>
233
+ <div class="space-y-4">
234
+ <div class="space-y-2">
235
+ <Label>{m.settings_theme()}</Label>
236
+ <p class="text-xs text-muted-foreground">{m.settings_appearance()}</p>
237
+ </div>
238
+ <div class="grid grid-cols-3 gap-2">
239
+ {#each themeOptions as opt (opt.key)}
240
+ <button
241
+ type="button"
242
+ onclick={() => pickTheme(opt.value)}
243
+ class={[
244
+ "rounded-md border px-3 py-2 text-sm font-medium transition-colors",
245
+ themeStore.value === opt.value
246
+ ? "border-primary bg-primary text-primary-foreground"
247
+ : "border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground",
248
+ ].join(" ")}
249
+ >
250
+ {opt.label}
251
+ </button>
252
+ {/each}
253
+ </div>
254
+ </div>
255
+ </Tabs.TabsContent>
256
+ </Tabs.Tabs>
257
+
258
+ <Dialog.DialogFooter>
259
+ <Button
260
+ id="logout-button"
261
+ variant="ghost"
262
+ onclick={handleLogout}
263
+ disabled={loggingOut}
264
+ class="mr-auto text-muted-foreground hover:text-destructive"
265
+ >
266
+ <LogOut class="mr-2 size-4" />
267
+ {loggingOut ? "…" : m.auth_logout()}
268
+ </Button>
269
+ <Button variant="outline" onclick={close}>{m.action_close()}</Button>
270
+ </Dialog.DialogFooter>
271
+ </Dialog.Dialog>
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ import { createShareLink } from "$lib/api/share";
3
+ import * as m from "$lib/paraglide/messages.js";
4
+
5
+ let {
6
+ open = $bindable(false),
7
+ documentId = "",
8
+ documentTitle = "",
9
+ }: {
10
+ open?: boolean;
11
+ documentId?: string;
12
+ documentTitle?: string;
13
+ } = $props();
14
+
15
+ let usePassword = $state(false);
16
+ let password = $state("");
17
+ let expiresIn = $state<"1h" | "1d" | "7d" | "30d" | "never">("7d");
18
+ let guestEmail = $state("");
19
+ let guestEmails = $state<string[]>([]);
20
+ let shareUrl = $state("");
21
+ let copied = $state(false);
22
+ let creating = $state(false);
23
+ let error = $state("");
24
+
25
+ function addGuest() {
26
+ const email = guestEmail.trim();
27
+ if (email?.includes("@") && !guestEmails.includes(email)) {
28
+ guestEmails = [...guestEmails, email];
29
+ guestEmail = "";
30
+ }
31
+ }
32
+
33
+ function removeGuest(email: string) {
34
+ guestEmails = guestEmails.filter((e) => e !== email);
35
+ }
36
+
37
+ async function createLink() {
38
+ creating = true;
39
+ error = "";
40
+ try {
41
+ const result = await createShareLink({
42
+ documentId: documentId || undefined,
43
+ password: usePassword ? password : undefined,
44
+ expiresIn,
45
+ guestEmails: guestEmails.length > 0 ? guestEmails : undefined,
46
+ });
47
+ shareUrl = `${window.location.origin}/s/${result.token}`;
48
+ } catch (e) {
49
+ error = e instanceof Error ? e.message : m.error_generic();
50
+ console.error("ShareDialog: createShareLink failed", e);
51
+ } finally {
52
+ creating = false;
53
+ }
54
+ }
55
+
56
+ async function copyLink() {
57
+ if (shareUrl) {
58
+ await navigator.clipboard.writeText(shareUrl);
59
+ copied = true;
60
+ setTimeout(() => {
61
+ copied = false;
62
+ }, 2000);
63
+ }
64
+ }
65
+
66
+ function close() {
67
+ open = false;
68
+ shareUrl = "";
69
+ usePassword = false;
70
+ password = "";
71
+ expiresIn = "7d";
72
+ guestEmails = [];
73
+ error = "";
74
+ }
75
+ </script>
76
+
77
+ {#if open}
78
+ <div class="fixed inset-0 z-50 flex items-center justify-center">
79
+ <button onclick={close} class="absolute inset-0 bg-black/50" aria-label={m.action_close()}></button>
80
+ <div class="relative z-10 w-full max-w-md rounded-lg border border-border bg-background p-6 shadow-lg">
81
+ <div class="mb-4 flex items-center justify-between">
82
+ <h2 class="text-lg font-semibold">{m.share_create_title()} "{documentTitle}"</h2>
83
+ <button onclick={close} class="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground" aria-label={m.action_close()}>&#10005;</button>
84
+ </div>
85
+
86
+ {#if !shareUrl}
87
+ <div class="space-y-4">
88
+ <div class="flex items-center justify-between">
89
+ <span class="text-sm font-medium">{m.share_password_protection()}</span>
90
+ <button
91
+ onclick={() => { usePassword = !usePassword; }}
92
+ class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {usePassword ? 'bg-primary' : 'bg-input'}"
93
+ role="switch"
94
+ aria-checked={usePassword}
95
+ aria-label={m.share_toggle_password()}
96
+ >
97
+ <span class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform {usePassword ? 'translate-x-4' : 'translate-x-0.5'}"></span>
98
+ </button>
99
+ </div>
100
+ {#if usePassword}
101
+ <input type="password" bind:value={password} placeholder={m.share_enter_password()} class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
102
+ {/if}
103
+
104
+ <div class="space-y-2">
105
+ <span class="text-sm font-medium">{m.share_expires()}</span>
106
+ <div class="flex gap-2">
107
+ {#each [["1h", m.share_expires_1h()], ["1d", m.share_expires_1d()], ["7d", m.share_expires_7d()], ["30d", m.share_expires_30d()], ["never", m.share_expires_never()]] as [val, label]}
108
+ <button
109
+ onclick={() => { expiresIn = val as typeof expiresIn; }}
110
+ class="rounded-md border px-2.5 py-1 text-xs font-medium transition-colors
111
+ {expiresIn === val ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-accent'}"
112
+ >
113
+ {label}
114
+ </button>
115
+ {/each}
116
+ </div>
117
+ </div>
118
+
119
+ <div class="space-y-2">
120
+ <span class="text-sm font-medium">{m.share_guest_access()}</span>
121
+ <div class="flex gap-2">
122
+ <input type="email" bind:value={guestEmail} placeholder="guest@email.com" onkeydown={(e) => { if (e.key === "Enter") { e.preventDefault(); addGuest(); } }} class="flex h-9 flex-1 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
123
+ <button onclick={addGuest} class="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">{m.share_add()}</button>
124
+ </div>
125
+ {#if guestEmails.length > 0}
126
+ <div class="flex flex-wrap gap-1.5">
127
+ {#each guestEmails as email}
128
+ <span class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-0.5 text-xs">
129
+ {email}
130
+ <button onclick={() => removeGuest(email)} class="text-muted-foreground hover:text-foreground">&times;</button>
131
+ </span>
132
+ {/each}
133
+ </div>
134
+ {/if}
135
+ </div>
136
+
137
+ {#if error}
138
+ <p class="text-xs text-destructive">{error}</p>
139
+ {/if}
140
+ <button onclick={createLink} disabled={creating} class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
141
+ {creating ? m.share_creating() : m.share_create_link()}
142
+ </button>
143
+ </div>
144
+ {:else}
145
+ <div class="space-y-4">
146
+ <p class="text-sm text-muted-foreground">{m.share_link_created()}</p>
147
+ <div class="flex items-center gap-2">
148
+ <code class="flex-1 truncate rounded-md bg-muted px-3 py-2 text-sm">{shareUrl}</code>
149
+ <button onclick={copyLink} class="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">
150
+ {copied ? m.share_link_copied() : m.share_copy()}
151
+ </button>
152
+ </div>
153
+ <button onclick={close} class="w-full rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent">{m.action_done()}</button>
154
+ </div>
155
+ {/if}
156
+ </div>
157
+ </div>
158
+ {/if}
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ import { apiFetch } from "$lib/api/client";
3
+ import * as m from "$lib/paraglide/messages.js";
4
+
5
+ const {
6
+ token = "",
7
+ expiresAt = "",
8
+ hasPassword = false,
9
+ guestEmails = [],
10
+ linkId = "",
11
+ onRevoke,
12
+ }: {
13
+ token?: string;
14
+ expiresAt?: string;
15
+ hasPassword?: boolean;
16
+ guestEmails?: string[];
17
+ linkId?: string;
18
+ onRevoke?: () => void;
19
+ } = $props();
20
+
21
+ let copied = $state(false);
22
+ let confirmRevoke = $state(false);
23
+
24
+ const shareUrl = $derived(
25
+ `${typeof window !== "undefined" ? window.location.origin : ""}/s/${token}`,
26
+ );
27
+
28
+ async function copyLink() {
29
+ await navigator.clipboard.writeText(shareUrl);
30
+ copied = true;
31
+ setTimeout(() => {
32
+ copied = false;
33
+ }, 2000);
34
+ }
35
+
36
+ function formatExpiry(dateStr: string): string {
37
+ if (!dateStr) return m.share_never_expires();
38
+ const date = new Date(dateStr);
39
+ const now = new Date();
40
+ if (date < now) return m.share_expired_label();
41
+ return m.share_expires_date({ date: date.toLocaleDateString() });
42
+ }
43
+
44
+ function removeGuest(email: string) {
45
+ if (!linkId) {
46
+ console.error("removeGuest called without linkId");
47
+ return;
48
+ }
49
+ apiFetch(`/api/share/${linkId}/guests/${encodeURIComponent(email)}`, {
50
+ method: "DELETE",
51
+ })
52
+ .then(() => {
53
+ // Backend already removed the row; consumer should re-fetch guest list
54
+ })
55
+ .catch((e: unknown) => console.error("Failed to remove guest", e));
56
+ }
57
+ </script>
58
+
59
+ <div class="rounded-lg border border-border bg-card p-4">
60
+ <div class="mb-3 flex items-center gap-2">
61
+ <code class="flex-1 truncate text-sm">{shareUrl}</code>
62
+ <button onclick={copyLink} class="rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent">
63
+ {copied ? m.share_link_copied() : m.share_copy()}
64
+ </button>
65
+ </div>
66
+
67
+ <div class="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
68
+ <span>{formatExpiry(expiresAt)}</span>
69
+ {#if hasPassword}
70
+ <span class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-0.5">
71
+ <svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
72
+ {m.share_password_label()}
73
+ </span>
74
+ {/if}
75
+ </div>
76
+
77
+ {#if guestEmails.length > 0}
78
+ <div class="mb-3 space-y-1">
79
+ <p class="text-xs font-medium text-muted-foreground">{m.share_guests()}</p>
80
+ {#each guestEmails as email}
81
+ <div class="flex items-center justify-between rounded bg-muted px-2 py-1 text-xs">
82
+ <span>{email}</span>
83
+ <button onclick={() => removeGuest(email)} class="text-muted-foreground hover:text-destructive" aria-label={m.attachment_remove()}>&times;</button>
84
+ </div>
85
+ {/each}
86
+ </div>
87
+ {/if}
88
+
89
+ {#if !confirmRevoke}
90
+ <button onclick={() => { confirmRevoke = true; }} class="text-xs font-medium text-destructive hover:underline">{m.share_revoke_link()}</button>
91
+ {:else}
92
+ <div class="flex items-center gap-2">
93
+ <span class="text-xs text-destructive">{m.share_revoke_confirm()}</span>
94
+ <button onclick={onRevoke} class="rounded bg-destructive px-2 py-0.5 text-xs font-medium text-destructive-foreground">{m.share_yes()}</button>
95
+ <button onclick={() => { confirmRevoke = false; }} class="rounded border border-border px-2 py-0.5 text-xs">{m.action_cancel()}</button>
96
+ </div>
97
+ {/if}
98
+ </div>