@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.
- package/.all-contributorsrc +18 -0
- package/.claude/settings.local.json +61 -0
- package/.dockerignore +113 -0
- package/.env.example +68 -0
- package/.github/FUNDING.yml +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
- package/.github/dependabot.yml +136 -0
- package/.github/pull_request_template.md +96 -0
- package/.github/workflows/ci.yml +283 -0
- package/AGENTS.md +237 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +77 -0
- package/Caddyfile +50 -0
- package/Dockerfile.backend +60 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/RELEASE_CHECKLIST.md +34 -0
- package/SECURITY.md +60 -0
- package/backend/package.json +43 -0
- package/backend/src/__tests__/auth-helpers.test.ts +51 -0
- package/backend/src/__tests__/chunker.test.ts +65 -0
- package/backend/src/__tests__/config.test.ts +91 -0
- package/backend/src/__tests__/csrf.test.ts +91 -0
- package/backend/src/__tests__/embedding.test.ts +48 -0
- package/backend/src/__tests__/rate-limit.test.ts +46 -0
- package/backend/src/__tests__/routes.test.ts +38 -0
- package/backend/src/__tests__/schema.test.ts +31 -0
- package/backend/src/__tests__/validation.test.ts +556 -0
- package/backend/src/api/middleware/auth.ts +56 -0
- package/backend/src/api/middleware/csrf.ts +91 -0
- package/backend/src/api/middleware/rate-limit.ts +77 -0
- package/backend/src/api/middleware/webhook-verify.ts +22 -0
- package/backend/src/api/routes/attachments.ts +280 -0
- package/backend/src/api/routes/auth.ts +52 -0
- package/backend/src/api/routes/collaboration.ts +121 -0
- package/backend/src/api/routes/documents.ts +664 -0
- package/backend/src/api/routes/folders.ts +226 -0
- package/backend/src/api/routes/search.ts +354 -0
- package/backend/src/api/routes/share.ts +512 -0
- package/backend/src/api/routes/tags.ts +247 -0
- package/backend/src/api/routes/versions.ts +99 -0
- package/backend/src/api/routes/webhooks.ts +43 -0
- package/backend/src/embedding/chunker.ts +74 -0
- package/backend/src/embedding/index.ts +117 -0
- package/backend/src/embedding/providers/ollama.ts +63 -0
- package/backend/src/embedding/providers/openrouter.ts +71 -0
- package/backend/src/embedding/utils.ts +13 -0
- package/backend/src/embedding/worker.ts +89 -0
- package/backend/src/index.ts +147 -0
- package/backend/src/lib/auth-helpers.ts +27 -0
- package/backend/src/lib/auth.ts +35 -0
- package/backend/src/lib/config.ts +73 -0
- package/backend/src/lib/db.ts +7 -0
- package/backend/src/lib/embedding-queue.ts +12 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/markdown-to-doc.ts +45 -0
- package/backend/src/lib/minio.ts +46 -0
- package/backend/src/lib/redis.ts +19 -0
- package/backend/src/lib/yjs-provider.ts +182 -0
- package/backend/tests/integration/_harness.ts +754 -0
- package/backend/tests/integration/auth.test.ts +296 -0
- package/backend/tests/integration/routes.documents.test.ts +459 -0
- package/backend/tests/integration/routes.folders.test.ts +337 -0
- package/backend/tests/integration/routes.search.test.ts +322 -0
- package/backend/tests/integration/routes.share.test.ts +773 -0
- package/backend/tests/integration/routes.tags.test.ts +425 -0
- package/backend/tests/integration/routes.versions.test.ts +233 -0
- package/backend/tsconfig.json +18 -0
- package/docker-compose.yml +218 -0
- package/docs/API.md +328 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/docs/DEPLOYMENT.md +113 -0
- package/docs/PRODUCTION_STATUS.md +61 -0
- package/docs/openapi.json +385 -0
- package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
- package/frontend/.svelte-kit.old/env.d.ts +1 -0
- package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
- package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
- package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
- package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
- package/frontend/.svelte-kit.old/tsconfig.json +59 -0
- package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
- package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
- package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
- package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
- package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
- package/frontend/Dockerfile +44 -0
- package/frontend/biome.json +40 -0
- package/frontend/components.json +18 -0
- package/frontend/messages/en.json +434 -0
- package/frontend/package.json +70 -0
- package/frontend/project.inlang/settings.json +12 -0
- package/frontend/src/app.css +6 -0
- package/frontend/src/app.d.ts +13 -0
- package/frontend/src/app.html +30 -0
- package/frontend/src/hooks.server.ts +10 -0
- package/frontend/src/hooks.ts +10 -0
- package/frontend/src/lib/api/attachments.ts +45 -0
- package/frontend/src/lib/api/client.test.ts +15 -0
- package/frontend/src/lib/api/client.ts +57 -0
- package/frontend/src/lib/api/documents.ts +83 -0
- package/frontend/src/lib/api/folders.ts +180 -0
- package/frontend/src/lib/api/search.test.ts +52 -0
- package/frontend/src/lib/api/search.ts +128 -0
- package/frontend/src/lib/api/settings.ts +95 -0
- package/frontend/src/lib/api/share.ts +71 -0
- package/frontend/src/lib/api/tags.test.ts +91 -0
- package/frontend/src/lib/api/tags.ts +87 -0
- package/frontend/src/lib/auth-client.ts +10 -0
- package/frontend/src/lib/collaboration.ts +63 -0
- package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
- package/frontend/src/lib/components/DatePicker.svelte +322 -0
- package/frontend/src/lib/components/DocumentCard.svelte +166 -0
- package/frontend/src/lib/components/EmptyState.svelte +49 -0
- package/frontend/src/lib/components/FolderCard.svelte +93 -0
- package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
- package/frontend/src/lib/components/SearchBar.svelte +47 -0
- package/frontend/src/lib/components/SearchResult.svelte +115 -0
- package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
- package/frontend/src/lib/components/ShareDialog.svelte +158 -0
- package/frontend/src/lib/components/ShareLink.svelte +98 -0
- package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
- package/frontend/src/lib/components/VersionDiff.svelte +55 -0
- package/frontend/src/lib/components/VersionHistory.svelte +96 -0
- package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
- package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
- package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
- package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
- package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
- package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
- package/frontend/src/lib/components/editor/markdown.ts +38 -0
- package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
- package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
- package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
- package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
- package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
- package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
- package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
- package/frontend/src/lib/stores/theme.svelte.ts +97 -0
- package/frontend/src/lib/svelte.d.ts +6 -0
- package/frontend/src/lib/types.ts +44 -0
- package/frontend/src/lib/utils/clipboard.ts +17 -0
- package/frontend/src/lib/utils/strip-markdown.ts +59 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/routes/(app)/+layout.svelte +17 -0
- package/frontend/src/routes/(app)/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/+page.svelte +303 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
- package/frontend/src/routes/(app)/search/+page.svelte +593 -0
- package/frontend/src/routes/(app)/search/+page.ts +25 -0
- package/frontend/src/routes/+error.svelte +12 -0
- package/frontend/src/routes/+layout.svelte +18 -0
- package/frontend/src/routes/+layout.ts +2 -0
- package/frontend/src/routes/api/[...path]/+server.ts +111 -0
- package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
- package/frontend/src/routes/folders/[id]/+page.ts +14 -0
- package/frontend/src/routes/login/+page.svelte +90 -0
- package/frontend/src/routes/register/+page.svelte +97 -0
- package/frontend/src/routes/s/[token]/+page.svelte +496 -0
- package/frontend/src/routes/s/[token]/+page.ts +5 -0
- package/frontend/src/routes/settings/+page.svelte +175 -0
- package/frontend/static/favicon.png +0 -0
- package/frontend/static/logo.png +0 -0
- package/frontend/svelte.config.js +15 -0
- package/frontend/tsconfig.json +15 -0
- package/frontend/vite.config.ts +25 -0
- package/init.sql +9 -0
- package/logo.png +0 -0
- package/package.json +39 -0
- package/package.public.json +39 -0
- package/packages/db/drizzle.config.ts +10 -0
- package/packages/db/package.json +30 -0
- package/packages/db/src/client.ts +9 -0
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
- package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
- package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/_journal.json +27 -0
- package/packages/db/src/schema.ts +378 -0
- package/packages/db/tsconfig.json +17 -0
- package/scripts/export-openapi.ts +37 -0
- package/scripts/health-check.sh +75 -0
- package/scripts/migrate.sh +135 -0
- package/scripts/prework_backup.sh +25 -0
- package/scripts/release.sh +83 -0
- 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()}>✕</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">×</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()}>×</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>
|