@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,311 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "@hiai-gg/hiai-ui/components/ui/button";
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from "@hiai-gg/hiai-ui/components/ui/dialog";
|
|
10
|
+
import {
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
} from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
|
|
16
|
+
import { Input } from "@hiai-gg/hiai-ui/components/ui/input";
|
|
17
|
+
import { Label } from "@hiai-gg/hiai-ui/components/ui/label";
|
|
18
|
+
import { Check, Copy, FileText, Loader2, MoreVertical } from "lucide-svelte";
|
|
19
|
+
import { onDestroy, onMount } from "svelte";
|
|
20
|
+
import {
|
|
21
|
+
type Document,
|
|
22
|
+
deleteDocument,
|
|
23
|
+
getDocument,
|
|
24
|
+
listDocuments,
|
|
25
|
+
updateDocument,
|
|
26
|
+
} from "$lib/api/documents";
|
|
27
|
+
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
|
28
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
29
|
+
import {
|
|
30
|
+
getDocRefreshNonce,
|
|
31
|
+
getSelectedTag,
|
|
32
|
+
refreshDocs,
|
|
33
|
+
} from "$lib/stores/tag-store.svelte";
|
|
34
|
+
import { copyToClipboard } from "$lib/utils/clipboard.js";
|
|
35
|
+
import { cn } from "$lib/utils.js";
|
|
36
|
+
|
|
37
|
+
let recentDocs = $state<Document[]>([]);
|
|
38
|
+
let activeId = $state<string | null>(null);
|
|
39
|
+
let loadError = $state<string | null>(null);
|
|
40
|
+
let copiedDocId = $state<string | null>(null);
|
|
41
|
+
let copyLoadingDocId = $state<string | null>(null);
|
|
42
|
+
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
43
|
+
|
|
44
|
+
// Rename dialog state.
|
|
45
|
+
let showRenameDialog = $state(false);
|
|
46
|
+
let renameTarget = $state<{ id: string; title: string } | null>(null);
|
|
47
|
+
let renameValue = $state("");
|
|
48
|
+
let renameError = $state<string | null>(null);
|
|
49
|
+
let renameSubmitting = $state(false);
|
|
50
|
+
|
|
51
|
+
// Delete confirmation state.
|
|
52
|
+
let showDeleteDialog = $state(false);
|
|
53
|
+
let deleteTarget = $state<{ id: string; title: string } | null>(null);
|
|
54
|
+
let deleteBusy = $state(false);
|
|
55
|
+
|
|
56
|
+
async function fetchRecentDocs() {
|
|
57
|
+
try {
|
|
58
|
+
const tag = getSelectedTag();
|
|
59
|
+
const res = await listDocuments({ limit: 6, ...(tag ? { tag } : {}) });
|
|
60
|
+
recentDocs = res.items;
|
|
61
|
+
loadError = null;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error("RecentDocs: failed to load recent documents", e);
|
|
64
|
+
loadError = "Failed to load recent documents";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onMount(() => {
|
|
69
|
+
void fetchRecentDocs();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Re-fetch the recent documents list whenever the global doc refresh
|
|
73
|
+
// nonce changes (e.g. after a dashboard import or another component
|
|
74
|
+
// calls refreshDocs()). Reading the nonce inside the effect registers
|
|
75
|
+
// it as a reactive dependency.
|
|
76
|
+
$effect(() => {
|
|
77
|
+
void getDocRefreshNonce();
|
|
78
|
+
// Re-filter when the shared selected tag changes (set from TagList).
|
|
79
|
+
void getSelectedTag();
|
|
80
|
+
void fetchRecentDocs();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
onDestroy(() => {
|
|
84
|
+
if (copyTimer) {
|
|
85
|
+
clearTimeout(copyTimer);
|
|
86
|
+
copyTimer = null;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
async function handleCopyContent(e: MouseEvent, docId: string) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
if (typeof window === "undefined") return;
|
|
94
|
+
// Copy the document's full markdown source. The list endpoint returns
|
|
95
|
+
// `content` truncated to 200 chars at the SQL level, so we fetch the
|
|
96
|
+
// single-document endpoint first to get the complete text. If the
|
|
97
|
+
// fetch fails we fall back to whatever the list payload already has
|
|
98
|
+
// (excerpt, then truncated content) so the button never silently
|
|
99
|
+
// does nothing.
|
|
100
|
+
const cached = recentDocs.find((d) => d.id === docId);
|
|
101
|
+
let text = "";
|
|
102
|
+
copyLoadingDocId = docId;
|
|
103
|
+
try {
|
|
104
|
+
const full = await getDocument(docId);
|
|
105
|
+
text = full.content ?? "";
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error("RecentDocs: failed to fetch full document for copy", err);
|
|
108
|
+
text = cached?.excerpt ?? cached?.content ?? "";
|
|
109
|
+
} finally {
|
|
110
|
+
copyLoadingDocId = null;
|
|
111
|
+
}
|
|
112
|
+
if (!text) return;
|
|
113
|
+
const ok = await copyToClipboard(text);
|
|
114
|
+
if (!ok) return;
|
|
115
|
+
copiedDocId = docId;
|
|
116
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
117
|
+
copyTimer = setTimeout(() => {
|
|
118
|
+
copiedDocId = null;
|
|
119
|
+
copyTimer = null;
|
|
120
|
+
}, 2000);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Rename / delete ---
|
|
124
|
+
function startRename(id: string, title: string) {
|
|
125
|
+
renameTarget = { id, title };
|
|
126
|
+
renameValue = title;
|
|
127
|
+
renameError = null;
|
|
128
|
+
showRenameDialog = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function closeRenameDialog() {
|
|
132
|
+
showRenameDialog = false;
|
|
133
|
+
renameTarget = null;
|
|
134
|
+
renameValue = "";
|
|
135
|
+
renameError = null;
|
|
136
|
+
renameSubmitting = false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function submitRename(e?: Event) {
|
|
140
|
+
e?.preventDefault();
|
|
141
|
+
const target = renameTarget;
|
|
142
|
+
if (!target) return;
|
|
143
|
+
const trimmed = renameValue.trim();
|
|
144
|
+
if (trimmed.length === 0) {
|
|
145
|
+
renameError = "Name is required";
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
renameSubmitting = true;
|
|
149
|
+
try {
|
|
150
|
+
await updateDocument(target.id, { title: trimmed });
|
|
151
|
+
closeRenameDialog();
|
|
152
|
+
await fetchRecentDocs();
|
|
153
|
+
// Notify the other sidebar lists (FolderTree) to refetch.
|
|
154
|
+
refreshDocs();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error("RecentDocs: rename failed", err);
|
|
157
|
+
renameError = err instanceof Error ? err.message : m.error_generic();
|
|
158
|
+
} finally {
|
|
159
|
+
renameSubmitting = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startDelete(id: string, title: string) {
|
|
164
|
+
deleteTarget = { id, title };
|
|
165
|
+
showDeleteDialog = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cancelDelete() {
|
|
169
|
+
showDeleteDialog = false;
|
|
170
|
+
deleteTarget = null;
|
|
171
|
+
deleteBusy = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function confirmDelete() {
|
|
175
|
+
const target = deleteTarget;
|
|
176
|
+
if (!target || deleteBusy) return;
|
|
177
|
+
deleteBusy = true;
|
|
178
|
+
try {
|
|
179
|
+
await deleteDocument(target.id);
|
|
180
|
+
cancelDelete();
|
|
181
|
+
await fetchRecentDocs();
|
|
182
|
+
refreshDocs();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.error("RecentDocs: delete failed", err);
|
|
185
|
+
loadError = err instanceof Error ? err.message : m.error_generic();
|
|
186
|
+
deleteBusy = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<div class="space-y-1">
|
|
192
|
+
<h3 class="mb-2 px-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">{m.sidebar_recent()}</h3>
|
|
193
|
+
{#if loadError}
|
|
194
|
+
<p class="px-2 text-xs text-destructive">{loadError}</p>
|
|
195
|
+
{/if}
|
|
196
|
+
{#each recentDocs as doc (doc.id)}
|
|
197
|
+
<div class="group/doc flex min-w-0 items-center gap-1">
|
|
198
|
+
<a
|
|
199
|
+
href={`/docs/${doc.id}`}
|
|
200
|
+
onclick={() => { activeId = doc.id; }}
|
|
201
|
+
class={cn(
|
|
202
|
+
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
203
|
+
activeId === doc.id && "bg-accent text-accent-foreground"
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<FileText class="size-4 shrink-0 text-muted-foreground" />
|
|
207
|
+
<div class="min-w-0 flex-1">
|
|
208
|
+
<p class="truncate min-w-0">{doc.title}</p>
|
|
209
|
+
<p class="text-xs text-muted-foreground">{doc.updatedAt}</p>
|
|
210
|
+
</div>
|
|
211
|
+
</a>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
class="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/doc:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring {copiedDocId === doc.id || copyLoadingDocId === doc.id ? 'opacity-100' : ''}"
|
|
215
|
+
aria-label={m.action_copy_content()}
|
|
216
|
+
title={m.action_copy_content()}
|
|
217
|
+
disabled={copyLoadingDocId === doc.id}
|
|
218
|
+
onclick={(e: MouseEvent) => void handleCopyContent(e, doc.id)}
|
|
219
|
+
>
|
|
220
|
+
{#if copyLoadingDocId === doc.id}
|
|
221
|
+
<Loader2 class="size-3.5 animate-spin" />
|
|
222
|
+
{:else if copiedDocId === doc.id}
|
|
223
|
+
<Check class="size-3.5" />
|
|
224
|
+
{:else}
|
|
225
|
+
<Copy class="size-3.5" />
|
|
226
|
+
{/if}
|
|
227
|
+
</button>
|
|
228
|
+
<DropdownMenu>
|
|
229
|
+
<DropdownMenuTrigger>
|
|
230
|
+
{#snippet child({ props })}
|
|
231
|
+
<button
|
|
232
|
+
{...props}
|
|
233
|
+
type="button"
|
|
234
|
+
class="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/doc:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
235
|
+
aria-label={m.editor_more_options()}
|
|
236
|
+
title={m.editor_more_options()}
|
|
237
|
+
onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}
|
|
238
|
+
>
|
|
239
|
+
<MoreVertical class="size-3.5" />
|
|
240
|
+
</button>
|
|
241
|
+
{/snippet}
|
|
242
|
+
</DropdownMenuTrigger>
|
|
243
|
+
<DropdownMenuContent align="end">
|
|
244
|
+
<DropdownMenuItem onSelect={() => startRename(doc.id, doc.title)}>
|
|
245
|
+
{m.folders_rename()}
|
|
246
|
+
</DropdownMenuItem>
|
|
247
|
+
<DropdownMenuItem
|
|
248
|
+
class="text-destructive focus:text-destructive"
|
|
249
|
+
onSelect={() => startDelete(doc.id, doc.title)}
|
|
250
|
+
>
|
|
251
|
+
{m.action_delete()}
|
|
252
|
+
</DropdownMenuItem>
|
|
253
|
+
</DropdownMenuContent>
|
|
254
|
+
</DropdownMenu>
|
|
255
|
+
</div>
|
|
256
|
+
{/each}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<!-- Rename dialog -->
|
|
260
|
+
<Dialog bind:open={showRenameDialog} onOpenChange={(next) => { if (!next) closeRenameDialog(); }}>
|
|
261
|
+
<DialogHeader>
|
|
262
|
+
<DialogTitle>{m.folders_rename()}</DialogTitle>
|
|
263
|
+
<DialogDescription>{m.doc_title_label()}</DialogDescription>
|
|
264
|
+
</DialogHeader>
|
|
265
|
+
|
|
266
|
+
<form onsubmit={submitRename} class="space-y-4">
|
|
267
|
+
<div class="space-y-2">
|
|
268
|
+
<Label for="recent-rename-input">{m.doc_title_label()}</Label>
|
|
269
|
+
<Input
|
|
270
|
+
id="recent-rename-input"
|
|
271
|
+
name="name"
|
|
272
|
+
type="text"
|
|
273
|
+
bind:value={renameValue}
|
|
274
|
+
maxlength={255}
|
|
275
|
+
required
|
|
276
|
+
disabled={renameSubmitting}
|
|
277
|
+
aria-invalid={renameError ? "true" : undefined}
|
|
278
|
+
aria-describedby={renameError ? "recent-rename-input-error" : undefined}
|
|
279
|
+
autocomplete="off"
|
|
280
|
+
/>
|
|
281
|
+
{#if renameError}
|
|
282
|
+
<p id="recent-rename-input-error" class="text-xs text-destructive" role="alert">{renameError}</p>
|
|
283
|
+
{/if}
|
|
284
|
+
</div>
|
|
285
|
+
</form>
|
|
286
|
+
|
|
287
|
+
<DialogFooter>
|
|
288
|
+
<Button variant="outline" type="button" onclick={closeRenameDialog} disabled={renameSubmitting}>
|
|
289
|
+
{m.action_cancel()}
|
|
290
|
+
</Button>
|
|
291
|
+
<Button
|
|
292
|
+
type="submit"
|
|
293
|
+
onclick={submitRename}
|
|
294
|
+
disabled={renameSubmitting || renameValue.trim().length === 0}
|
|
295
|
+
>
|
|
296
|
+
{renameSubmitting ? m.action_loading() : m.action_save()}
|
|
297
|
+
</Button>
|
|
298
|
+
</DialogFooter>
|
|
299
|
+
</Dialog>
|
|
300
|
+
|
|
301
|
+
<!-- Delete confirmation -->
|
|
302
|
+
<ConfirmDialog
|
|
303
|
+
bind:open={showDeleteDialog}
|
|
304
|
+
title={m.doc_delete()}
|
|
305
|
+
description={m.doc_delete_confirm()}
|
|
306
|
+
confirmLabel={m.action_delete()}
|
|
307
|
+
variant="destructive"
|
|
308
|
+
busy={deleteBusy}
|
|
309
|
+
onConfirm={confirmDelete}
|
|
310
|
+
onCancel={cancelDelete}
|
|
311
|
+
/>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Clock,
|
|
4
|
+
Folder,
|
|
5
|
+
PanelLeftClose,
|
|
6
|
+
PanelLeftOpen,
|
|
7
|
+
Search,
|
|
8
|
+
Settings as SettingsIcon,
|
|
9
|
+
Tag,
|
|
10
|
+
} from "lucide-svelte";
|
|
11
|
+
import { goto } from "$app/navigation";
|
|
12
|
+
import SearchBar from "$lib/components/SearchBar.svelte";
|
|
13
|
+
import SettingsDialog from "$lib/components/SettingsDialog.svelte";
|
|
14
|
+
import FolderTree from "$lib/components/sidebar/FolderTree.svelte";
|
|
15
|
+
import RecentDocs from "$lib/components/sidebar/RecentDocs.svelte";
|
|
16
|
+
import TagList from "$lib/components/sidebar/TagList.svelte";
|
|
17
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
18
|
+
import { cn } from "$lib/utils";
|
|
19
|
+
|
|
20
|
+
let collapsed = $state(false);
|
|
21
|
+
let showSettings = $state(false);
|
|
22
|
+
type PanelMode = "all" | "recent" | "tags";
|
|
23
|
+
let activePanel = $state<PanelMode>("all");
|
|
24
|
+
|
|
25
|
+
function openSearch() {
|
|
26
|
+
goto("/search");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function togglePanel(mode: PanelMode) {
|
|
30
|
+
activePanel = activePanel === mode ? "all" : mode;
|
|
31
|
+
collapsed = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toggleCollapse() {
|
|
35
|
+
collapsed = !collapsed;
|
|
36
|
+
if (collapsed) {
|
|
37
|
+
activePanel = "all";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<aside class={cn(
|
|
43
|
+
"relative flex h-screen flex-col border-r border-border bg-card transition-[width] duration-200",
|
|
44
|
+
collapsed ? "w-12" : "w-64"
|
|
45
|
+
)}>
|
|
46
|
+
<!-- Toggle -->
|
|
47
|
+
<button
|
|
48
|
+
onclick={toggleCollapse}
|
|
49
|
+
class="absolute -right-3 top-4 z-50 flex size-6 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-accent"
|
|
50
|
+
>
|
|
51
|
+
{#if collapsed}
|
|
52
|
+
<PanelLeftOpen class="size-3.5" />
|
|
53
|
+
{:else}
|
|
54
|
+
<PanelLeftClose class="size-3.5" />
|
|
55
|
+
{/if}
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
{#if !collapsed}
|
|
59
|
+
<div class="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
|
|
60
|
+
<!-- Search — leave a right gap so the collapse toggle button
|
|
61
|
+
(positioned at the panel's top-right edge) stays clear of it. -->
|
|
62
|
+
<SearchBar class="mr-5" />
|
|
63
|
+
|
|
64
|
+
{#if activePanel === "all"}
|
|
65
|
+
<!-- Folders -->
|
|
66
|
+
<FolderTree />
|
|
67
|
+
|
|
68
|
+
<!-- Separator -->
|
|
69
|
+
<div class="h-px bg-border"></div>
|
|
70
|
+
{/if}
|
|
71
|
+
|
|
72
|
+
{#if activePanel === "all" || activePanel === "recent"}
|
|
73
|
+
<!-- Recent Docs -->
|
|
74
|
+
<RecentDocs />
|
|
75
|
+
|
|
76
|
+
<!-- Separator -->
|
|
77
|
+
<div class="h-px bg-border"></div>
|
|
78
|
+
{/if}
|
|
79
|
+
|
|
80
|
+
{#if activePanel === "all" || activePanel === "tags"}
|
|
81
|
+
<!-- Tags -->
|
|
82
|
+
<TagList />
|
|
83
|
+
{/if}
|
|
84
|
+
</div>
|
|
85
|
+
{:else}
|
|
86
|
+
<div class="flex flex-1 flex-col items-center gap-1 pt-14">
|
|
87
|
+
<button
|
|
88
|
+
onclick={() => goto("/")}
|
|
89
|
+
class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
90
|
+
title={m.sidebar_folders()}
|
|
91
|
+
aria-label={m.sidebar_folders()}
|
|
92
|
+
>
|
|
93
|
+
<Folder class="size-4" />
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
onclick={openSearch}
|
|
97
|
+
class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
98
|
+
title={m.search_title()}
|
|
99
|
+
aria-label={m.search_title()}
|
|
100
|
+
>
|
|
101
|
+
<Search class="size-4" />
|
|
102
|
+
</button>
|
|
103
|
+
<button
|
|
104
|
+
onclick={() => togglePanel("recent")}
|
|
105
|
+
class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
106
|
+
title={m.sidebar_recent()}
|
|
107
|
+
aria-label={m.sidebar_recent()}
|
|
108
|
+
>
|
|
109
|
+
<Clock class="size-4" />
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
onclick={() => togglePanel("tags")}
|
|
113
|
+
class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
114
|
+
title={m.doc_tags()}
|
|
115
|
+
aria-label={m.doc_tags()}
|
|
116
|
+
>
|
|
117
|
+
<Tag class="size-4" />
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
{/if}
|
|
121
|
+
|
|
122
|
+
<div class={cn("p-2", collapsed ? "flex flex-col items-center gap-2" : "space-y-2")}>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onclick={() => { showSettings = true; }}
|
|
126
|
+
class={cn(
|
|
127
|
+
"flex items-center rounded-md text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
128
|
+
collapsed ? "size-8 justify-center" : "w-full gap-2 px-2 py-1.5"
|
|
129
|
+
)}
|
|
130
|
+
title={m.settings_title()}
|
|
131
|
+
aria-label={m.settings_title()}
|
|
132
|
+
>
|
|
133
|
+
<SettingsIcon class="size-4 shrink-0" />
|
|
134
|
+
{#if !collapsed}
|
|
135
|
+
<span class="truncate">{m.settings_title()}</span>
|
|
136
|
+
{/if}
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="border-t border-border p-2">
|
|
140
|
+
<a
|
|
141
|
+
href="https://hiai.gg/docs"
|
|
142
|
+
target="_blank"
|
|
143
|
+
rel="noopener noreferrer"
|
|
144
|
+
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
145
|
+
title={m.sidebar_powered_by()}
|
|
146
|
+
>
|
|
147
|
+
{#if collapsed}
|
|
148
|
+
HiAi
|
|
149
|
+
{:else}
|
|
150
|
+
{m.sidebar_powered_by()}
|
|
151
|
+
{/if}
|
|
152
|
+
</a>
|
|
153
|
+
</div>
|
|
154
|
+
</aside>
|
|
155
|
+
|
|
156
|
+
<SettingsDialog bind:open={showSettings} />
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
<!-- TagList.svelte — Sidebar list of tags with filter toggle, create, edit, delete. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuTrigger,
|
|
8
|
+
} from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
|
|
9
|
+
import { Loader2, MoreVertical, Plus } from "lucide-svelte";
|
|
10
|
+
import { onMount } from "svelte";
|
|
11
|
+
import { deleteTag, listTags, type Tag } from "$lib/api/tags";
|
|
12
|
+
import TagCreateDialog from "$lib/components/TagCreateDialog.svelte";
|
|
13
|
+
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
|
14
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
15
|
+
import {
|
|
16
|
+
getSelectedTag,
|
|
17
|
+
getTagRefreshNonce,
|
|
18
|
+
refreshTags,
|
|
19
|
+
setSelectedTag,
|
|
20
|
+
} from "$lib/stores/tag-store.svelte";
|
|
21
|
+
import { cn } from "$lib/utils";
|
|
22
|
+
|
|
23
|
+
let tags = $state<Tag[]>([]);
|
|
24
|
+
let loadError = $state<string | null>(null);
|
|
25
|
+
let showCreateDialog = $state(false);
|
|
26
|
+
let editTarget = $state<Tag | null>(null);
|
|
27
|
+
let showDeleteDialog = $state(false);
|
|
28
|
+
let deleteTarget = $state<Tag | null>(null);
|
|
29
|
+
let busy = $state(false);
|
|
30
|
+
|
|
31
|
+
async function refresh() {
|
|
32
|
+
try {
|
|
33
|
+
tags = await listTags();
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error("TagList: failed to load tags", e);
|
|
36
|
+
loadError = m.tags_load_error();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onMount(() => {
|
|
41
|
+
void refresh();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// React to the global refresh nonce so tag mutations from other parts of
|
|
45
|
+
// the app (e.g. the document editor) reflect here without a page reload.
|
|
46
|
+
$effect(() => {
|
|
47
|
+
getTagRefreshNonce();
|
|
48
|
+
void refresh();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function handleCreated(created: Tag) {
|
|
52
|
+
// Optimistically add the new tag to the list so the user sees it
|
|
53
|
+
// appear immediately. The next listTags() roundtrip will reconcile.
|
|
54
|
+
tags = [...tags, created];
|
|
55
|
+
refreshTags();
|
|
56
|
+
void refresh();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleUpdated(updated: Tag) {
|
|
60
|
+
tags = tags.map((t) => (t.id === updated.id ? updated : t));
|
|
61
|
+
refreshTags();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startEdit(t: Tag) {
|
|
65
|
+
editTarget = t;
|
|
66
|
+
showCreateDialog = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleDialogClose() {
|
|
70
|
+
// Clear the edit target whenever the dialog is dismissed so the next
|
|
71
|
+
// open defaults back to create mode.
|
|
72
|
+
editTarget = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function startDelete(t: Tag) {
|
|
76
|
+
deleteTarget = t;
|
|
77
|
+
showDeleteDialog = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cancelDelete() {
|
|
81
|
+
showDeleteDialog = false;
|
|
82
|
+
deleteTarget = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function confirmDelete() {
|
|
86
|
+
const t = deleteTarget;
|
|
87
|
+
if (!t || busy) return;
|
|
88
|
+
busy = true;
|
|
89
|
+
try {
|
|
90
|
+
await deleteTag(t.id);
|
|
91
|
+
tags = tags.filter((tag) => tag.id !== t.id);
|
|
92
|
+
if (getSelectedTag() === t.id) setSelectedTag(null);
|
|
93
|
+
showDeleteDialog = false;
|
|
94
|
+
deleteTarget = null;
|
|
95
|
+
refreshTags();
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error("TagList: deleteTag failed", e);
|
|
98
|
+
loadError = m.error_generic();
|
|
99
|
+
} finally {
|
|
100
|
+
busy = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<div class="space-y-1">
|
|
106
|
+
<h3 class="mb-2 px-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
107
|
+
{m.doc_tags()}
|
|
108
|
+
</h3>
|
|
109
|
+
{#if loadError}
|
|
110
|
+
<p class="px-2 text-xs text-destructive">{loadError}</p>
|
|
111
|
+
{/if}
|
|
112
|
+
<div class="flex flex-wrap gap-1 px-2">
|
|
113
|
+
{#each tags as tag (tag.id)}
|
|
114
|
+
<div
|
|
115
|
+
class={cn(
|
|
116
|
+
"group/tag relative inline-flex items-center rounded-full transition-colors",
|
|
117
|
+
getSelectedTag() === tag.id
|
|
118
|
+
? "bg-primary text-primary-foreground"
|
|
119
|
+
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onclick={() => setSelectedTag(getSelectedTag() === tag.id ? null : tag.id, tag.name)}
|
|
125
|
+
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
|
126
|
+
aria-pressed={getSelectedTag() === tag.id}
|
|
127
|
+
>
|
|
128
|
+
<span
|
|
129
|
+
class="size-2.5 shrink-0 rounded-full"
|
|
130
|
+
style="background-color: {tag.color || '#888888'}"
|
|
131
|
+
></span>
|
|
132
|
+
{tag.name}
|
|
133
|
+
</button>
|
|
134
|
+
<DropdownMenu>
|
|
135
|
+
<DropdownMenuTrigger>
|
|
136
|
+
{#snippet child({ props })}
|
|
137
|
+
<button
|
|
138
|
+
{...props}
|
|
139
|
+
type="button"
|
|
140
|
+
class={cn(
|
|
141
|
+
"mr-0.5 inline-flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity hover:bg-black/10 focus-visible:opacity-100 focus-visible:outline-none group-hover/tag:opacity-100",
|
|
142
|
+
getSelectedTag() === tag.id && "opacity-100",
|
|
143
|
+
)}
|
|
144
|
+
aria-label={m.editor_more_options()}
|
|
145
|
+
title={m.editor_more_options()}
|
|
146
|
+
disabled={busy}
|
|
147
|
+
>
|
|
148
|
+
{#if busy}
|
|
149
|
+
<Loader2 class="size-3 animate-spin" />
|
|
150
|
+
{:else}
|
|
151
|
+
<MoreVertical class="size-3" />
|
|
152
|
+
{/if}
|
|
153
|
+
</button>
|
|
154
|
+
{/snippet}
|
|
155
|
+
</DropdownMenuTrigger>
|
|
156
|
+
<DropdownMenuContent align="start">
|
|
157
|
+
<DropdownMenuItem onSelect={() => startEdit(tag)}>
|
|
158
|
+
{m.action_edit()}
|
|
159
|
+
</DropdownMenuItem>
|
|
160
|
+
<DropdownMenuItem
|
|
161
|
+
class="text-destructive focus:text-destructive"
|
|
162
|
+
onSelect={() => startDelete(tag)}
|
|
163
|
+
>
|
|
164
|
+
{m.action_delete()}
|
|
165
|
+
</DropdownMenuItem>
|
|
166
|
+
</DropdownMenuContent>
|
|
167
|
+
</DropdownMenu>
|
|
168
|
+
</div>
|
|
169
|
+
{/each}
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onclick={() => { editTarget = null; showCreateDialog = true; }}
|
|
173
|
+
class="inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-secondary-foreground"
|
|
174
|
+
aria-label={m.tags_new()}
|
|
175
|
+
>
|
|
176
|
+
<Plus class="size-3" />
|
|
177
|
+
{m.tags_add()}
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<TagCreateDialog
|
|
183
|
+
bind:open={showCreateDialog}
|
|
184
|
+
mode={editTarget ? "edit" : "create"}
|
|
185
|
+
tag={editTarget}
|
|
186
|
+
onCreated={handleCreated}
|
|
187
|
+
onUpdated={handleUpdated}
|
|
188
|
+
onClose={handleDialogClose}
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
<ConfirmDialog
|
|
192
|
+
bind:open={showDeleteDialog}
|
|
193
|
+
title={m.tags_delete_title()}
|
|
194
|
+
description={m.tags_delete_description()}
|
|
195
|
+
confirmLabel={m.action_delete()}
|
|
196
|
+
variant="destructive"
|
|
197
|
+
busy={busy}
|
|
198
|
+
onConfirm={confirmDelete}
|
|
199
|
+
onCancel={cancelDelete}
|
|
200
|
+
/>
|