@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,166 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge } from "@hiai-gg/hiai-ui/components/ui/badge";
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardHeader,
|
|
7
|
+
} from "@hiai-gg/hiai-ui/components/ui/card";
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
|
|
15
|
+
import {
|
|
16
|
+
ArrowUpRight,
|
|
17
|
+
Check,
|
|
18
|
+
Copy,
|
|
19
|
+
Files,
|
|
20
|
+
FileText,
|
|
21
|
+
FolderInput,
|
|
22
|
+
Loader2,
|
|
23
|
+
MoreVertical,
|
|
24
|
+
Trash2,
|
|
25
|
+
} from "lucide-svelte";
|
|
26
|
+
import { goto } from "$app/navigation";
|
|
27
|
+
import { getDocument } from "$lib/api/documents";
|
|
28
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
29
|
+
import type { Document } from "$lib/types.js";
|
|
30
|
+
import { copyToClipboard } from "$lib/utils/clipboard.js";
|
|
31
|
+
import { stripMarkdown } from "$lib/utils/strip-markdown";
|
|
32
|
+
import { cn, formatRelativeTime } from "$lib/utils.js";
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
document: doc,
|
|
36
|
+
onDelete,
|
|
37
|
+
onDuplicate,
|
|
38
|
+
}: {
|
|
39
|
+
document: Document;
|
|
40
|
+
onDelete?: (id: string) => void;
|
|
41
|
+
onDuplicate?: (id: string) => void;
|
|
42
|
+
} = $props();
|
|
43
|
+
|
|
44
|
+
function navigateToDoc() {
|
|
45
|
+
goto(`/docs/${doc.id}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
49
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
navigateToDoc();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let contentCopied = $state(false);
|
|
56
|
+
let contentCopying = $state(false);
|
|
57
|
+
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
58
|
+
|
|
59
|
+
async function handleCopyContent(e: Event) {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
if (typeof window === "undefined") return;
|
|
62
|
+
// Copy the document's full markdown source. The list endpoint returns
|
|
63
|
+
// `content` truncated to 200 chars at the SQL level, so we fetch the
|
|
64
|
+
// single-document endpoint first to get the complete text. If the
|
|
65
|
+
// fetch fails we fall back to the card payload (excerpt, then
|
|
66
|
+
// truncated content) so the menu item still does something.
|
|
67
|
+
let text = "";
|
|
68
|
+
contentCopying = true;
|
|
69
|
+
try {
|
|
70
|
+
const full = await getDocument(doc.id);
|
|
71
|
+
text = full.content ?? "";
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error("DocumentCard: failed to fetch full document for copy", err);
|
|
74
|
+
text = doc.excerpt || doc.content || "";
|
|
75
|
+
} finally {
|
|
76
|
+
contentCopying = false;
|
|
77
|
+
}
|
|
78
|
+
if (!text) return;
|
|
79
|
+
const ok = await copyToClipboard(text);
|
|
80
|
+
if (!ok) return;
|
|
81
|
+
contentCopied = true;
|
|
82
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
83
|
+
copyTimer = setTimeout(() => {
|
|
84
|
+
contentCopied = false;
|
|
85
|
+
copyTimer = null;
|
|
86
|
+
}, 2000);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const preview = $derived(
|
|
90
|
+
stripMarkdown(doc.excerpt || doc.content || "").slice(0, 100),
|
|
91
|
+
);
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<Card
|
|
95
|
+
class="group cursor-pointer transition-shadow duration-200 hover:shadow-md"
|
|
96
|
+
onclick={navigateToDoc}
|
|
97
|
+
onkeydown={handleKeydown}
|
|
98
|
+
role="button"
|
|
99
|
+
tabindex={0}
|
|
100
|
+
>
|
|
101
|
+
<CardHeader class="flex-row items-start justify-between space-y-0 pb-2">
|
|
102
|
+
<div class="flex items-center gap-2 text-muted-foreground">
|
|
103
|
+
<FileText class="size-4 shrink-0" />
|
|
104
|
+
<span class="text-xs">{m.doc_updated({ time: formatRelativeTime(doc.updatedAt) })}</span>
|
|
105
|
+
</div>
|
|
106
|
+
<DropdownMenu>
|
|
107
|
+
<DropdownMenuTrigger
|
|
108
|
+
class="inline-flex size-8 items-center justify-center rounded-md opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
|
|
109
|
+
onclick={(e: MouseEvent) => e.stopPropagation()}
|
|
110
|
+
>
|
|
111
|
+
<MoreVertical class="size-4" />
|
|
112
|
+
<span class="sr-only">{m.doc_open_menu()}</span>
|
|
113
|
+
</DropdownMenuTrigger>
|
|
114
|
+
<DropdownMenuContent align="end">
|
|
115
|
+
<DropdownMenuItem onclick={() => goto(`/docs/${doc.id}`)}>
|
|
116
|
+
<ArrowUpRight class="size-4" />
|
|
117
|
+
{m.doc_open()}
|
|
118
|
+
</DropdownMenuItem>
|
|
119
|
+
<DropdownMenuItem onclick={handleCopyContent} disabled={contentCopying}>
|
|
120
|
+
{#if contentCopying}
|
|
121
|
+
<Loader2 class="size-4 animate-spin" />
|
|
122
|
+
{m.action_copy_content()}
|
|
123
|
+
{:else if contentCopied}
|
|
124
|
+
<Check class="size-4" />
|
|
125
|
+
{m.share_copied()}
|
|
126
|
+
{:else}
|
|
127
|
+
<Copy class="size-4" />
|
|
128
|
+
{m.action_copy_content()}
|
|
129
|
+
{/if}
|
|
130
|
+
</DropdownMenuItem>
|
|
131
|
+
<DropdownMenuItem onclick={(e: Event) => { e.stopPropagation(); onDuplicate?.(doc.id); }}>
|
|
132
|
+
<Files class="size-4" />
|
|
133
|
+
{m.doc_duplicate()}
|
|
134
|
+
</DropdownMenuItem>
|
|
135
|
+
<DropdownMenuItem onclick={(e: Event) => e.stopPropagation()}>
|
|
136
|
+
<FolderInput class="size-4" />
|
|
137
|
+
{m.doc_move_to_folder()}
|
|
138
|
+
</DropdownMenuItem>
|
|
139
|
+
<DropdownMenuSeparator />
|
|
140
|
+
<DropdownMenuItem
|
|
141
|
+
class="text-destructive"
|
|
142
|
+
onclick={(e: Event) => { e.stopPropagation(); onDelete?.(doc.id); }}
|
|
143
|
+
>
|
|
144
|
+
<Trash2 class="size-4" />
|
|
145
|
+
{m.action_delete()}
|
|
146
|
+
</DropdownMenuItem>
|
|
147
|
+
</DropdownMenuContent>
|
|
148
|
+
</DropdownMenu>
|
|
149
|
+
</CardHeader>
|
|
150
|
+
<CardContent>
|
|
151
|
+
<h3 class="mb-1 truncate text-sm font-medium leading-snug">{doc.title}</h3>
|
|
152
|
+
{#if preview}
|
|
153
|
+
<p class="mb-3 line-clamp-2 text-xs text-muted-foreground">{preview}</p>
|
|
154
|
+
{/if}
|
|
155
|
+
{#if doc.tags.length > 0}
|
|
156
|
+
<div class="flex flex-wrap gap-1">
|
|
157
|
+
{#each doc.tags.slice(0, 3) as tag (tag)}
|
|
158
|
+
<Badge variant="secondary" class="text-[10px]">{tag}</Badge>
|
|
159
|
+
{/each}
|
|
160
|
+
{#if doc.tags.length > 3}
|
|
161
|
+
<Badge variant="outline" class="text-[10px]">+{doc.tags.length - 3}</Badge>
|
|
162
|
+
{/if}
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
165
|
+
</CardContent>
|
|
166
|
+
</Card>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from "$lib/utils.js";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
icon?: typeof import("lucide-svelte").FileText;
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
actionLabel?: string;
|
|
9
|
+
onAction?: () => void;
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
icon: Icon,
|
|
15
|
+
title,
|
|
16
|
+
description,
|
|
17
|
+
actionLabel,
|
|
18
|
+
onAction,
|
|
19
|
+
class: className,
|
|
20
|
+
}: Props = $props();
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<div
|
|
24
|
+
class={cn(
|
|
25
|
+
"flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-12 text-center",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
{#if Icon}
|
|
30
|
+
<div class="mb-4 rounded-full bg-muted p-4">
|
|
31
|
+
<Icon class="h-8 w-8 text-muted-foreground" strokeWidth={1.5} />
|
|
32
|
+
</div>
|
|
33
|
+
{/if}
|
|
34
|
+
|
|
35
|
+
<h3 class="text-lg font-medium text-foreground">{title}</h3>
|
|
36
|
+
|
|
37
|
+
{#if description}
|
|
38
|
+
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
39
|
+
{/if}
|
|
40
|
+
|
|
41
|
+
{#if actionLabel && onAction}
|
|
42
|
+
<button
|
|
43
|
+
onclick={onAction}
|
|
44
|
+
class="mt-4 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
|
45
|
+
>
|
|
46
|
+
{actionLabel}
|
|
47
|
+
</button>
|
|
48
|
+
{/if}
|
|
49
|
+
</div>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Card, CardContent } from "@hiai-gg/hiai-ui/components/ui/card";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
|
|
10
|
+
import {
|
|
11
|
+
Folder,
|
|
12
|
+
FolderInput,
|
|
13
|
+
MoreVertical,
|
|
14
|
+
Pencil,
|
|
15
|
+
Trash2,
|
|
16
|
+
} from "lucide-svelte";
|
|
17
|
+
import { goto } from "$app/navigation";
|
|
18
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
19
|
+
import type { Folder as FolderType } from "$lib/types.js";
|
|
20
|
+
import { formatRelativeTime } from "$lib/utils.js";
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
folder,
|
|
24
|
+
onDelete,
|
|
25
|
+
onRename,
|
|
26
|
+
}: {
|
|
27
|
+
folder: FolderType;
|
|
28
|
+
onDelete?: (id: string) => void;
|
|
29
|
+
onRename?: (id: string) => void;
|
|
30
|
+
} = $props();
|
|
31
|
+
|
|
32
|
+
function navigateToFolder() {
|
|
33
|
+
goto(`/folders/${folder.id}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
37
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
navigateToFolder();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<Card
|
|
45
|
+
class="group cursor-pointer transition-shadow duration-200 hover:shadow-md"
|
|
46
|
+
onclick={navigateToFolder}
|
|
47
|
+
onkeydown={handleKeydown}
|
|
48
|
+
role="button"
|
|
49
|
+
tabindex={0}
|
|
50
|
+
>
|
|
51
|
+
<CardContent class="flex items-center gap-3 p-4">
|
|
52
|
+
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
53
|
+
<Folder class="size-5 text-primary" />
|
|
54
|
+
</div>
|
|
55
|
+
<div class="min-w-0 flex-1">
|
|
56
|
+
<h3 class="truncate text-sm font-medium">{folder.name}</h3>
|
|
57
|
+
<p class="text-xs text-muted-foreground">
|
|
58
|
+
{folder.documentCount} {folder.documentCount === 1 ? m.folders_document() : m.folders_documents()} · {formatRelativeTime(folder.updatedAt)}
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
<DropdownMenu>
|
|
62
|
+
<DropdownMenuTrigger
|
|
63
|
+
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
|
|
64
|
+
onclick={(e: MouseEvent) => e.stopPropagation()}
|
|
65
|
+
>
|
|
66
|
+
<MoreVertical class="size-4" />
|
|
67
|
+
<span class="sr-only">{m.doc_open_menu()}</span>
|
|
68
|
+
</DropdownMenuTrigger>
|
|
69
|
+
<DropdownMenuContent align="end">
|
|
70
|
+
<DropdownMenuItem onclick={() => goto(`/folders/${folder.id}`)}>
|
|
71
|
+
<Folder class="size-4" />
|
|
72
|
+
{m.doc_open()}
|
|
73
|
+
</DropdownMenuItem>
|
|
74
|
+
<DropdownMenuItem onclick={(e: Event) => { e.stopPropagation(); onRename?.(folder.id); }}>
|
|
75
|
+
<Pencil class="size-4" />
|
|
76
|
+
{m.folders_rename()}
|
|
77
|
+
</DropdownMenuItem>
|
|
78
|
+
<DropdownMenuItem onclick={(e: Event) => e.stopPropagation()}>
|
|
79
|
+
<FolderInput class="size-4" />
|
|
80
|
+
{m.folders_move()}
|
|
81
|
+
</DropdownMenuItem>
|
|
82
|
+
<DropdownMenuSeparator />
|
|
83
|
+
<DropdownMenuItem
|
|
84
|
+
class="text-destructive"
|
|
85
|
+
onclick={(e: Event) => { e.stopPropagation(); onDelete?.(folder.id); }}
|
|
86
|
+
>
|
|
87
|
+
<Trash2 class="size-4" />
|
|
88
|
+
{m.action_delete()}
|
|
89
|
+
</DropdownMenuItem>
|
|
90
|
+
</DropdownMenuContent>
|
|
91
|
+
</DropdownMenu>
|
|
92
|
+
</CardContent>
|
|
93
|
+
</Card>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ArrowUp } from "lucide-svelte";
|
|
3
|
+
import { onDestroy } from "svelte";
|
|
4
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
5
|
+
|
|
6
|
+
const SCROLL_THRESHOLD = 300;
|
|
7
|
+
|
|
8
|
+
let { scrollTarget }: { scrollTarget?: HTMLElement | null } = $props();
|
|
9
|
+
|
|
10
|
+
let visible = $state(false);
|
|
11
|
+
let activeTarget: HTMLElement | null = null;
|
|
12
|
+
|
|
13
|
+
function handleScroll() {
|
|
14
|
+
visible =
|
|
15
|
+
(activeTarget ? activeTarget.scrollTop : window.scrollY) > SCROLL_THRESHOLD;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scrollToTop() {
|
|
19
|
+
if (activeTarget) {
|
|
20
|
+
activeTarget.scrollTo({ top: 0, behavior: "smooth" });
|
|
21
|
+
} else {
|
|
22
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function attach(target: HTMLElement | null | undefined) {
|
|
27
|
+
detach();
|
|
28
|
+
if (!target) return;
|
|
29
|
+
activeTarget = target;
|
|
30
|
+
target.addEventListener("scroll", handleScroll, { passive: true });
|
|
31
|
+
handleScroll();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detach() {
|
|
35
|
+
if (activeTarget) {
|
|
36
|
+
activeTarget.removeEventListener("scroll", handleScroll);
|
|
37
|
+
}
|
|
38
|
+
activeTarget = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// React to changes in scrollTarget so we always listen on the right element.
|
|
42
|
+
// When scrollTarget is undefined, fall back to window-level scroll.
|
|
43
|
+
$effect(() => {
|
|
44
|
+
const target = scrollTarget;
|
|
45
|
+
attach(target);
|
|
46
|
+
if (!target) {
|
|
47
|
+
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
48
|
+
handleScroll();
|
|
49
|
+
return () => {
|
|
50
|
+
window.removeEventListener("scroll", handleScroll);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return () => {
|
|
54
|
+
detach();
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
onDestroy(() => {
|
|
59
|
+
detach();
|
|
60
|
+
visible = false;
|
|
61
|
+
});
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
class="fixed bottom-6 right-6 z-50 inline-flex size-10 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-md transition-all hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring {visible ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}"
|
|
67
|
+
aria-label={m.scroll_to_top_aria()}
|
|
68
|
+
title={m.scroll_to_top_aria()}
|
|
69
|
+
onclick={scrollToTop}
|
|
70
|
+
>
|
|
71
|
+
<ArrowUp class="size-5" />
|
|
72
|
+
</button>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Search, X } from "lucide-svelte";
|
|
3
|
+
import { goto } from "$app/navigation";
|
|
4
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
5
|
+
import { cn } from "$lib/utils.js";
|
|
6
|
+
|
|
7
|
+
const { class: className }: { class?: string } = $props();
|
|
8
|
+
let query = $state("");
|
|
9
|
+
let inputEl = $state<HTMLInputElement | null>(null);
|
|
10
|
+
|
|
11
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
12
|
+
if (e.key === "Enter" && query.trim()) {
|
|
13
|
+
goto(`/search?q=${encodeURIComponent(query.trim())}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function clearQuery() {
|
|
18
|
+
query = "";
|
|
19
|
+
inputEl?.focus();
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<div class={cn("relative", className)}>
|
|
24
|
+
<Search class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
25
|
+
<input
|
|
26
|
+
bind:this={inputEl}
|
|
27
|
+
type="text"
|
|
28
|
+
bind:value={query}
|
|
29
|
+
onkeydown={handleKeydown}
|
|
30
|
+
placeholder={m.search_placeholder()}
|
|
31
|
+
class={cn(
|
|
32
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent pl-9 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
33
|
+
query ? "pr-9" : "pr-3"
|
|
34
|
+
)}
|
|
35
|
+
/>
|
|
36
|
+
{#if query}
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onclick={clearQuery}
|
|
40
|
+
class="absolute right-2 top-1/2 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
41
|
+
aria-label={m.search_clear()}
|
|
42
|
+
title={m.search_clear()}
|
|
43
|
+
>
|
|
44
|
+
<X class="size-3.5" />
|
|
45
|
+
</button>
|
|
46
|
+
{/if}
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Calendar, Folder, Tag } from "lucide-svelte";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
snippet: string; // may contain <mark> tags
|
|
8
|
+
score: number;
|
|
9
|
+
folderName: string;
|
|
10
|
+
tags: Array<{ id: string; name: string; color: string | null }>;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
query?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
id,
|
|
17
|
+
title,
|
|
18
|
+
snippet,
|
|
19
|
+
score,
|
|
20
|
+
folderName,
|
|
21
|
+
tags,
|
|
22
|
+
createdAt,
|
|
23
|
+
query = "",
|
|
24
|
+
}: Props = $props();
|
|
25
|
+
|
|
26
|
+
function escapeHtml(text: string): string {
|
|
27
|
+
return text
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/'/g, "'");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function highlightText(text: string, q: string): string {
|
|
36
|
+
if (!q) return escapeHtml(text);
|
|
37
|
+
const escapedQuery = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
|
+
const safe = escapeHtml(text);
|
|
39
|
+
return safe.replace(new RegExp(`(${escapedQuery})`, "gi"), "<mark>$1</mark>");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const highlightedSnippet = $derived(highlightText(snippet, query));
|
|
43
|
+
|
|
44
|
+
const scorePercent = $derived(Math.round(score * 100));
|
|
45
|
+
|
|
46
|
+
const scoreColor = $derived(
|
|
47
|
+
scorePercent >= 90
|
|
48
|
+
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
|
49
|
+
: scorePercent >= 75
|
|
50
|
+
? "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
|
|
51
|
+
: "bg-muted text-muted-foreground",
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const formattedDate = $derived(
|
|
55
|
+
new Date(createdAt).toLocaleDateString("en-US", {
|
|
56
|
+
year: "numeric",
|
|
57
|
+
month: "short",
|
|
58
|
+
day: "numeric",
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<a
|
|
64
|
+
href="/docs/{id}"
|
|
65
|
+
class="group block rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/30 hover:shadow-md"
|
|
66
|
+
>
|
|
67
|
+
<!-- Header: title + score -->
|
|
68
|
+
<div class="flex items-start justify-between gap-3">
|
|
69
|
+
<h3
|
|
70
|
+
class="text-lg font-semibold leading-snug text-foreground group-hover:text-primary transition-colors"
|
|
71
|
+
>
|
|
72
|
+
{title}
|
|
73
|
+
</h3>
|
|
74
|
+
<span
|
|
75
|
+
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {scoreColor}"
|
|
76
|
+
>
|
|
77
|
+
{scorePercent}%
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<!-- Snippet with highlighted terms -->
|
|
82
|
+
<p
|
|
83
|
+
class="mt-2 text-sm leading-relaxed text-muted-foreground [&_mark]:rounded-sm [&_mark]:bg-yellow-200 [&_mark]:px-0.5 [&_mark]:text-foreground dark:[&_mark]:bg-yellow-900/60"
|
|
84
|
+
>
|
|
85
|
+
{@html highlightedSnippet}
|
|
86
|
+
</p>
|
|
87
|
+
|
|
88
|
+
<!-- Meta row: folder + tags + date -->
|
|
89
|
+
<div
|
|
90
|
+
class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground"
|
|
91
|
+
>
|
|
92
|
+
{#if folderName}
|
|
93
|
+
<span class="inline-flex items-center gap-1">
|
|
94
|
+
<Folder class="size-3.5" />
|
|
95
|
+
{folderName}
|
|
96
|
+
</span>
|
|
97
|
+
{/if}
|
|
98
|
+
|
|
99
|
+
{#if tags.length > 0}
|
|
100
|
+
{#each tags as tag (tag.id)}
|
|
101
|
+
<span class="inline-flex items-center gap-1 rounded-full bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
|
|
102
|
+
{#if tag.color}
|
|
103
|
+
<span class="inline-block size-2 rounded-full" style="background-color: {tag.color}"></span>
|
|
104
|
+
{/if}
|
|
105
|
+
{tag.name}
|
|
106
|
+
</span>
|
|
107
|
+
{/each}
|
|
108
|
+
{/if}
|
|
109
|
+
|
|
110
|
+
<span class="inline-flex items-center gap-1">
|
|
111
|
+
<Calendar class="size-3.5" />
|
|
112
|
+
{formattedDate}
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
115
|
+
</a>
|