@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,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createTag,
|
|
4
|
+
createTagInputSchema,
|
|
5
|
+
deleteTag,
|
|
6
|
+
getTag,
|
|
7
|
+
updateTag,
|
|
8
|
+
updateTagInputSchema,
|
|
9
|
+
} from "./tags";
|
|
10
|
+
|
|
11
|
+
describe("createTagInputSchema", () => {
|
|
12
|
+
test("accepts a non-empty name", () => {
|
|
13
|
+
const result = createTagInputSchema.safeParse({ name: "design" });
|
|
14
|
+
expect(result.success).toBe(true);
|
|
15
|
+
if (result.success) {
|
|
16
|
+
expect(result.data.name).toBe("design");
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("trims surrounding whitespace", () => {
|
|
21
|
+
const result = createTagInputSchema.safeParse({ name: " design " });
|
|
22
|
+
expect(result.success).toBe(true);
|
|
23
|
+
if (result.success) {
|
|
24
|
+
expect(result.data.name).toBe("design");
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects an empty name", () => {
|
|
29
|
+
const result = createTagInputSchema.safeParse({ name: "" });
|
|
30
|
+
expect(result.success).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects a whitespace-only name after trim", () => {
|
|
34
|
+
const result = createTagInputSchema.safeParse({ name: " " });
|
|
35
|
+
expect(result.success).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects a name over 50 chars", () => {
|
|
39
|
+
const result = createTagInputSchema.safeParse({ name: "x".repeat(51) });
|
|
40
|
+
expect(result.success).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("accepts a name of exactly 50 chars", () => {
|
|
44
|
+
const result = createTagInputSchema.safeParse({ name: "x".repeat(50) });
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("updateTagInputSchema", () => {
|
|
50
|
+
test("accepts a name update", () => {
|
|
51
|
+
const result = updateTagInputSchema.safeParse({ name: "engineering" });
|
|
52
|
+
expect(result.success).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("rejects an empty name", () => {
|
|
56
|
+
const result = updateTagInputSchema.safeParse({ name: "" });
|
|
57
|
+
expect(result.success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("tag api functions", () => {
|
|
62
|
+
test("createTag is a function", () => {
|
|
63
|
+
expect(typeof createTag).toBe("function");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("updateTag is a function", () => {
|
|
67
|
+
expect(typeof updateTag).toBe("function");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("deleteTag is a function", () => {
|
|
71
|
+
expect(typeof deleteTag).toBe("function");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("getTag is a function", () => {
|
|
75
|
+
expect(typeof getTag).toBe("function");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("createTag validates input via schema", () => {
|
|
79
|
+
expect(() => createTag("")).toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("createTag validates length via schema", () => {
|
|
83
|
+
expect(() => createTag("x".repeat(51))).toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("updateTag validates input via schema", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
updateTag("550e8400-e29b-41d4-a716-446655440000", { name: "" }),
|
|
89
|
+
).toThrow();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiFetch } from "./client.js";
|
|
3
|
+
|
|
4
|
+
export interface Tag {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
color: string | null;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
documentCount?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Frontend cap is 50 chars; backend hard limit is 100 (see backend/src/api/routes/tags.ts).
|
|
13
|
+
|
|
14
|
+
export const createTagInputSchema = z.object({
|
|
15
|
+
name: z
|
|
16
|
+
.string()
|
|
17
|
+
.trim()
|
|
18
|
+
.min(1, "Name is required")
|
|
19
|
+
.max(50, "Name must be 50 characters or less"),
|
|
20
|
+
color: z.string().max(20, "Color must be 20 characters or less").optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const updateTagInputSchema = z.object({
|
|
24
|
+
name: z
|
|
25
|
+
.string()
|
|
26
|
+
.trim()
|
|
27
|
+
.min(1, "Name is required")
|
|
28
|
+
.max(50, "Name must be 50 characters or less")
|
|
29
|
+
.optional(),
|
|
30
|
+
color: z.string().max(20, "Color must be 20 characters or less").optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type CreateTagInput = z.infer<typeof createTagInputSchema>;
|
|
34
|
+
export type UpdateTagInput = z.infer<typeof updateTagInputSchema>;
|
|
35
|
+
|
|
36
|
+
// --- API Functions ---
|
|
37
|
+
|
|
38
|
+
export async function listTags(): Promise<Tag[]> {
|
|
39
|
+
return apiFetch("/api/tags");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getTag(id: string): Promise<Tag> {
|
|
43
|
+
return apiFetch(`/api/tags/${id}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createTag(name: string, color?: string): Promise<Tag> {
|
|
47
|
+
const input = createTagInputSchema.parse({ name, color });
|
|
48
|
+
return apiFetch("/api/tags", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
body: JSON.stringify(input),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function updateTag(
|
|
55
|
+
id: string,
|
|
56
|
+
data: { name?: string; color?: string },
|
|
57
|
+
): Promise<Tag> {
|
|
58
|
+
const input = updateTagInputSchema.parse(data);
|
|
59
|
+
return apiFetch(`/api/tags/${id}`, {
|
|
60
|
+
method: "PATCH",
|
|
61
|
+
body: JSON.stringify(input),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function deleteTag(id: string): Promise<void> {
|
|
66
|
+
return apiFetch(`/api/tags/${id}`, { method: "DELETE" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function addTagToDocument(
|
|
70
|
+
documentId: string,
|
|
71
|
+
tagId: string,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const input = z.object({ tagId: z.string().uuid() }).parse({ tagId });
|
|
74
|
+
return apiFetch(`/api/documents/${documentId}/tags`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
body: JSON.stringify(input),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function removeTagFromDocument(
|
|
81
|
+
documentId: string,
|
|
82
|
+
tagId: string,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
return apiFetch(`/api/documents/${documentId}/tags/${tagId}`, {
|
|
85
|
+
method: "DELETE",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createAuthClient } from "better-auth/client";
|
|
2
|
+
|
|
3
|
+
export const authClient = createAuthClient({
|
|
4
|
+
// Use the current origin so cookies are set on the same origin as the
|
|
5
|
+
// frontend (e.g. http://localhost:50701) rather than the backend port.
|
|
6
|
+
// Auth requests are forwarded by the SvelteKit proxy at /api/[...path].
|
|
7
|
+
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const { signIn, signUp, signOut, getSession } = authClient;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { WebsocketProvider } from "y-websocket";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
|
|
4
|
+
export interface CollaborationSession {
|
|
5
|
+
provider: WebsocketProvider;
|
|
6
|
+
doc: Y.Doc;
|
|
7
|
+
destroy: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let activeSession: CollaborationSession | null = null;
|
|
11
|
+
|
|
12
|
+
export function startCollaboration(
|
|
13
|
+
documentId: string,
|
|
14
|
+
accessToken: string,
|
|
15
|
+
onUpdate?: (update: Uint8Array) => void,
|
|
16
|
+
): CollaborationSession {
|
|
17
|
+
stopCollaboration();
|
|
18
|
+
|
|
19
|
+
const doc = new Y.Doc();
|
|
20
|
+
const wsUrl = `ws://${window.location.hostname}:${window.location.port}/api/ws/collab/${documentId}?token=${encodeURIComponent(accessToken)}`;
|
|
21
|
+
|
|
22
|
+
const provider = new WebsocketProvider(wsUrl, documentId, doc, {
|
|
23
|
+
connect: true,
|
|
24
|
+
params: { token: accessToken },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
provider.on("sync", (_synced: boolean) => {});
|
|
28
|
+
|
|
29
|
+
provider.on("status", (_status: { status: string }) => {});
|
|
30
|
+
|
|
31
|
+
provider.on("connection-close", () => {});
|
|
32
|
+
|
|
33
|
+
const updateHandler = onUpdate;
|
|
34
|
+
if (updateHandler) {
|
|
35
|
+
doc.on("update", updateHandler);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
activeSession = {
|
|
39
|
+
provider,
|
|
40
|
+
doc,
|
|
41
|
+
destroy: () => {
|
|
42
|
+
if (updateHandler) {
|
|
43
|
+
doc.off("update", updateHandler);
|
|
44
|
+
}
|
|
45
|
+
provider.disconnect();
|
|
46
|
+
provider.destroy();
|
|
47
|
+
doc.destroy();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return activeSession;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function stopCollaboration() {
|
|
55
|
+
if (activeSession) {
|
|
56
|
+
activeSession.destroy();
|
|
57
|
+
activeSession = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getActiveSession(): CollaborationSession | null {
|
|
62
|
+
return activeSession;
|
|
63
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
documentId = "",
|
|
6
|
+
onUpload,
|
|
7
|
+
}: {
|
|
8
|
+
documentId?: string;
|
|
9
|
+
onUpload?: (file: File) => void;
|
|
10
|
+
} = $props();
|
|
11
|
+
|
|
12
|
+
let dragOver = $state(false);
|
|
13
|
+
let uploadedFile = $state<File | null>(null);
|
|
14
|
+
let error = $state("");
|
|
15
|
+
let inputRef: HTMLInputElement | undefined = $state();
|
|
16
|
+
|
|
17
|
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
18
|
+
const accept = ".jpg,.jpeg,.png,.gif,.webp,.pdf,.md,.txt,.csv,.json";
|
|
19
|
+
|
|
20
|
+
function formatSize(bytes: number): string {
|
|
21
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
22
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
23
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateFile(file: File): boolean {
|
|
27
|
+
if (file.size > maxSize) {
|
|
28
|
+
error = m.attachment_file_too_large({ size: formatSize(file.size) });
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
error = "";
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function handleFiles(files: FileList | null) {
|
|
36
|
+
if (!files?.[0]) return;
|
|
37
|
+
const file = files[0];
|
|
38
|
+
if (validateFile(file)) {
|
|
39
|
+
uploadedFile = file;
|
|
40
|
+
onUpload?.(file);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleDrop(e: DragEvent) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
dragOver = false;
|
|
47
|
+
handleFiles(e.dataTransfer?.files ?? null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleDragOver(e: DragEvent) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
dragOver = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleDragLeave() {
|
|
56
|
+
dragOver = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function openPicker() {
|
|
60
|
+
inputRef?.click();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function removeFile() {
|
|
64
|
+
uploadedFile = null;
|
|
65
|
+
error = "";
|
|
66
|
+
if (inputRef) inputRef.value = "";
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<div class="space-y-2">
|
|
71
|
+
{#if !uploadedFile}
|
|
72
|
+
<button
|
|
73
|
+
onclick={openPicker}
|
|
74
|
+
ondrop={handleDrop}
|
|
75
|
+
ondragover={handleDragOver}
|
|
76
|
+
ondragleave={handleDragLeave}
|
|
77
|
+
class="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors
|
|
78
|
+
{dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground/50'}"
|
|
79
|
+
>
|
|
80
|
+
<svg class="h-8 w-8 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
81
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
82
|
+
<polyline points="17 8 12 3 7 8"></polyline>
|
|
83
|
+
<line x1="12" y1="3" x2="12" y2="15"></line>
|
|
84
|
+
</svg>
|
|
85
|
+
<span class="text-sm font-medium">{m.attachment_drop_here()}</span>
|
|
86
|
+
<span class="text-xs text-muted-foreground">{m.attachment_types_hint()}</span>
|
|
87
|
+
</button>
|
|
88
|
+
<input bind:this={inputRef} type="file" {accept} onchange={(e) => handleFiles(e.currentTarget.files)} class="hidden" />
|
|
89
|
+
{:else}
|
|
90
|
+
<div class="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
|
|
91
|
+
<div class="flex h-8 w-8 items-center justify-center rounded bg-muted text-xs font-medium">
|
|
92
|
+
{uploadedFile.name.split(".").pop()?.toUpperCase() ?? "FILE"}
|
|
93
|
+
</div>
|
|
94
|
+
<div class="flex-1 min-w-0">
|
|
95
|
+
<p class="truncate text-sm font-medium">{uploadedFile.name}</p>
|
|
96
|
+
<p class="text-xs text-muted-foreground">{formatSize(uploadedFile.size)}</p>
|
|
97
|
+
</div>
|
|
98
|
+
<button onclick={removeFile} class="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground" aria-label={m.attachment_remove()}>
|
|
99
|
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
100
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
101
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
102
|
+
</svg>
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
{/if}
|
|
106
|
+
|
|
107
|
+
{#if error}
|
|
108
|
+
<p class="text-xs text-destructive">{error}</p>
|
|
109
|
+
{/if}
|
|
110
|
+
</div>
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
<!-- DatePicker.svelte — Self-contained, theme-token-styled date picker.
|
|
2
|
+
Replaces the native <input type="date"> whose popup calendar ignores
|
|
3
|
+
`accent-color` in Chrome and renders a fixed blue. This calendar uses
|
|
4
|
+
our CSS variables, so the selected day follows the brand accent
|
|
5
|
+
(--primary) in both light and dark themes. Value is an ISO date string
|
|
6
|
+
("YYYY-MM-DD") or "" when unset. -->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { Calendar, ChevronLeft, ChevronRight } from "lucide-svelte";
|
|
9
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
value = $bindable(""),
|
|
13
|
+
onchange,
|
|
14
|
+
id,
|
|
15
|
+
ariaLabel = m.date_picker_aria(),
|
|
16
|
+
placeholder = m.date_picker_placeholder(),
|
|
17
|
+
}: {
|
|
18
|
+
value?: string;
|
|
19
|
+
onchange?: () => void;
|
|
20
|
+
id?: string;
|
|
21
|
+
ariaLabel?: string;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
let open = $state(false);
|
|
26
|
+
let root = $state<HTMLDivElement | null>(null);
|
|
27
|
+
|
|
28
|
+
const WEEKDAYS = [
|
|
29
|
+
m.weekday_mo(),
|
|
30
|
+
m.weekday_tu(),
|
|
31
|
+
m.weekday_we(),
|
|
32
|
+
m.weekday_th(),
|
|
33
|
+
m.weekday_fr(),
|
|
34
|
+
m.weekday_sa(),
|
|
35
|
+
m.weekday_su(),
|
|
36
|
+
];
|
|
37
|
+
const MONTHS = [
|
|
38
|
+
m.month_january(),
|
|
39
|
+
m.month_february(),
|
|
40
|
+
m.month_march(),
|
|
41
|
+
m.month_april(),
|
|
42
|
+
m.month_may(),
|
|
43
|
+
m.month_june(),
|
|
44
|
+
m.month_july(),
|
|
45
|
+
m.month_august(),
|
|
46
|
+
m.month_september(),
|
|
47
|
+
m.month_october(),
|
|
48
|
+
m.month_november(),
|
|
49
|
+
m.month_december(),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function pad(n: number): string {
|
|
53
|
+
return String(n).padStart(2, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parse(v: string): { y: number; m: number; d: number } | null {
|
|
57
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
|
|
58
|
+
if (!match) return null;
|
|
59
|
+
return { y: Number(match[1]), m: Number(match[2]) - 1, d: Number(match[3]) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const today = new Date();
|
|
63
|
+
let viewYear = $state(today.getFullYear());
|
|
64
|
+
let viewMonth = $state(today.getMonth());
|
|
65
|
+
|
|
66
|
+
// Sync the visible month to the selected value when opening.
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (open) {
|
|
69
|
+
const p = parse(value);
|
|
70
|
+
if (p) {
|
|
71
|
+
viewYear = p.y;
|
|
72
|
+
viewMonth = p.m;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const selected = $derived(parse(value));
|
|
78
|
+
|
|
79
|
+
function daysGrid(): Array<number | null> {
|
|
80
|
+
const firstWeekday = new Date(viewYear, viewMonth, 1).getDay(); // 0 = Sun
|
|
81
|
+
const offset = (firstWeekday + 6) % 7; // shift to Monday-first
|
|
82
|
+
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
|
83
|
+
const cells: Array<number | null> = [];
|
|
84
|
+
for (let i = 0; i < offset; i++) cells.push(null);
|
|
85
|
+
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
|
86
|
+
return cells;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isToday(d: number): boolean {
|
|
90
|
+
return (
|
|
91
|
+
d === today.getDate() &&
|
|
92
|
+
viewMonth === today.getMonth() &&
|
|
93
|
+
viewYear === today.getFullYear()
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isSelected(d: number): boolean {
|
|
98
|
+
return (
|
|
99
|
+
selected !== null &&
|
|
100
|
+
selected.y === viewYear &&
|
|
101
|
+
selected.m === viewMonth &&
|
|
102
|
+
selected.d === d
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pick(d: number) {
|
|
107
|
+
value = `${viewYear}-${pad(viewMonth + 1)}-${pad(d)}`;
|
|
108
|
+
open = false;
|
|
109
|
+
onchange?.();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function clear() {
|
|
113
|
+
value = "";
|
|
114
|
+
open = false;
|
|
115
|
+
onchange?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function prevMonth() {
|
|
119
|
+
if (viewMonth === 0) {
|
|
120
|
+
viewMonth = 11;
|
|
121
|
+
viewYear -= 1;
|
|
122
|
+
} else {
|
|
123
|
+
viewMonth -= 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function nextMonth() {
|
|
128
|
+
if (viewMonth === 11) {
|
|
129
|
+
viewMonth = 0;
|
|
130
|
+
viewYear += 1;
|
|
131
|
+
} else {
|
|
132
|
+
viewMonth += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
$effect(() => {
|
|
137
|
+
if (!open) return;
|
|
138
|
+
function onDocPointer(e: PointerEvent) {
|
|
139
|
+
if (root && !root.contains(e.target as Node)) open = false;
|
|
140
|
+
}
|
|
141
|
+
function onKey(e: KeyboardEvent) {
|
|
142
|
+
if (e.key === "Escape") open = false;
|
|
143
|
+
}
|
|
144
|
+
document.addEventListener("pointerdown", onDocPointer);
|
|
145
|
+
document.addEventListener("keydown", onKey);
|
|
146
|
+
return () => {
|
|
147
|
+
document.removeEventListener("pointerdown", onDocPointer);
|
|
148
|
+
document.removeEventListener("keydown", onKey);
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<div class="datepicker" bind:this={root}>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
{id}
|
|
157
|
+
aria-label={ariaLabel}
|
|
158
|
+
aria-haspopup="dialog"
|
|
159
|
+
aria-expanded={open}
|
|
160
|
+
onclick={() => (open = !open)}
|
|
161
|
+
class="dp-trigger"
|
|
162
|
+
>
|
|
163
|
+
<Calendar class="size-3.5 shrink-0 opacity-70" />
|
|
164
|
+
<span class={value ? "" : "dp-placeholder"}>{value || placeholder}</span>
|
|
165
|
+
</button>
|
|
166
|
+
|
|
167
|
+
{#if open}
|
|
168
|
+
<div class="dp-popover" role="dialog" aria-label={ariaLabel}>
|
|
169
|
+
<div class="dp-header">
|
|
170
|
+
<button type="button" class="dp-nav" onclick={prevMonth} aria-label={m.date_picker_prev_month()}>
|
|
171
|
+
<ChevronLeft class="size-4" />
|
|
172
|
+
</button>
|
|
173
|
+
<span class="dp-title">{MONTHS[viewMonth]} {viewYear}</span>
|
|
174
|
+
<button type="button" class="dp-nav" onclick={nextMonth} aria-label={m.date_picker_next_month()}>
|
|
175
|
+
<ChevronRight class="size-4" />
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="dp-weekdays">
|
|
179
|
+
{#each WEEKDAYS as wd (wd)}<span class="dp-weekday">{wd}</span>{/each}
|
|
180
|
+
</div>
|
|
181
|
+
<div class="dp-grid">
|
|
182
|
+
{#each daysGrid() as d, i (i)}
|
|
183
|
+
{#if d === null}
|
|
184
|
+
<span class="dp-empty"></span>
|
|
185
|
+
{:else}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
class="dp-day"
|
|
189
|
+
class:selected={isSelected(d)}
|
|
190
|
+
class:today={isToday(d)}
|
|
191
|
+
onclick={() => pick(d)}
|
|
192
|
+
>{d}</button>
|
|
193
|
+
{/if}
|
|
194
|
+
{/each}
|
|
195
|
+
</div>
|
|
196
|
+
{#if value}
|
|
197
|
+
<button type="button" class="dp-clear" onclick={clear}>{m.date_picker_clear()}</button>
|
|
198
|
+
{/if}
|
|
199
|
+
</div>
|
|
200
|
+
{/if}
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<style>
|
|
204
|
+
.datepicker {
|
|
205
|
+
position: relative;
|
|
206
|
+
}
|
|
207
|
+
.dp-trigger {
|
|
208
|
+
display: inline-flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
gap: 6px;
|
|
211
|
+
height: 2rem;
|
|
212
|
+
width: 100%;
|
|
213
|
+
padding: 0 0.625rem;
|
|
214
|
+
border: 1px solid var(--input);
|
|
215
|
+
border-radius: 6px;
|
|
216
|
+
background: transparent;
|
|
217
|
+
color: var(--foreground);
|
|
218
|
+
font-size: 0.875rem;
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
text-align: left;
|
|
221
|
+
}
|
|
222
|
+
.dp-trigger:focus-visible {
|
|
223
|
+
outline: none;
|
|
224
|
+
box-shadow: 0 0 0 1px var(--ring);
|
|
225
|
+
}
|
|
226
|
+
.dp-placeholder {
|
|
227
|
+
color: var(--muted-foreground);
|
|
228
|
+
}
|
|
229
|
+
.dp-popover {
|
|
230
|
+
position: absolute;
|
|
231
|
+
z-index: 60;
|
|
232
|
+
top: calc(100% + 4px);
|
|
233
|
+
left: 0;
|
|
234
|
+
width: 16rem;
|
|
235
|
+
padding: 8px;
|
|
236
|
+
background: var(--popover);
|
|
237
|
+
color: var(--popover-foreground);
|
|
238
|
+
border: 1px solid var(--border);
|
|
239
|
+
border-radius: 8px;
|
|
240
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
|
241
|
+
}
|
|
242
|
+
.dp-header {
|
|
243
|
+
display: flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
justify-content: space-between;
|
|
246
|
+
margin-bottom: 6px;
|
|
247
|
+
}
|
|
248
|
+
.dp-title {
|
|
249
|
+
font-size: 0.8125rem;
|
|
250
|
+
font-weight: 600;
|
|
251
|
+
}
|
|
252
|
+
.dp-nav {
|
|
253
|
+
display: inline-flex;
|
|
254
|
+
align-items: center;
|
|
255
|
+
justify-content: center;
|
|
256
|
+
width: 1.75rem;
|
|
257
|
+
height: 1.75rem;
|
|
258
|
+
border-radius: 6px;
|
|
259
|
+
background: transparent;
|
|
260
|
+
border: none;
|
|
261
|
+
color: var(--muted-foreground);
|
|
262
|
+
cursor: pointer;
|
|
263
|
+
}
|
|
264
|
+
.dp-nav:hover {
|
|
265
|
+
background: var(--accent);
|
|
266
|
+
color: var(--accent-foreground);
|
|
267
|
+
}
|
|
268
|
+
.dp-weekdays,
|
|
269
|
+
.dp-grid {
|
|
270
|
+
display: grid;
|
|
271
|
+
grid-template-columns: repeat(7, 1fr);
|
|
272
|
+
gap: 2px;
|
|
273
|
+
}
|
|
274
|
+
.dp-weekday {
|
|
275
|
+
text-align: center;
|
|
276
|
+
font-size: 0.6875rem;
|
|
277
|
+
color: var(--muted-foreground);
|
|
278
|
+
padding: 2px 0;
|
|
279
|
+
}
|
|
280
|
+
.dp-empty {
|
|
281
|
+
aspect-ratio: 1;
|
|
282
|
+
}
|
|
283
|
+
.dp-day {
|
|
284
|
+
display: inline-flex;
|
|
285
|
+
align-items: center;
|
|
286
|
+
justify-content: center;
|
|
287
|
+
aspect-ratio: 1;
|
|
288
|
+
border-radius: 6px;
|
|
289
|
+
border: none;
|
|
290
|
+
background: transparent;
|
|
291
|
+
color: var(--foreground);
|
|
292
|
+
font-size: 0.8125rem;
|
|
293
|
+
cursor: pointer;
|
|
294
|
+
}
|
|
295
|
+
.dp-day:hover {
|
|
296
|
+
background: var(--accent);
|
|
297
|
+
color: var(--accent-foreground);
|
|
298
|
+
}
|
|
299
|
+
.dp-day.today {
|
|
300
|
+
box-shadow: inset 0 0 0 1px var(--ring);
|
|
301
|
+
}
|
|
302
|
+
.dp-day.selected,
|
|
303
|
+
.dp-day.selected:hover {
|
|
304
|
+
background: var(--primary);
|
|
305
|
+
color: var(--primary-foreground);
|
|
306
|
+
}
|
|
307
|
+
.dp-clear {
|
|
308
|
+
margin-top: 6px;
|
|
309
|
+
width: 100%;
|
|
310
|
+
padding: 4px 0;
|
|
311
|
+
font-size: 0.75rem;
|
|
312
|
+
color: var(--muted-foreground);
|
|
313
|
+
background: transparent;
|
|
314
|
+
border: 1px solid var(--border);
|
|
315
|
+
border-radius: 6px;
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
.dp-clear:hover {
|
|
319
|
+
background: var(--accent);
|
|
320
|
+
color: var(--accent-foreground);
|
|
321
|
+
}
|
|
322
|
+
</style>
|