@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,731 @@
|
|
|
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 {
|
|
19
|
+
Check,
|
|
20
|
+
ChevronRight,
|
|
21
|
+
Copy,
|
|
22
|
+
FileText,
|
|
23
|
+
Folder,
|
|
24
|
+
Loader2,
|
|
25
|
+
MoreVertical,
|
|
26
|
+
Plus,
|
|
27
|
+
} from "lucide-svelte";
|
|
28
|
+
import { onMount } from "svelte";
|
|
29
|
+
import { flip } from "svelte/animate";
|
|
30
|
+
import { type DndEvent, dndzone } from "svelte-dnd-action";
|
|
31
|
+
import { page } from "$app/state";
|
|
32
|
+
import {
|
|
33
|
+
type Document,
|
|
34
|
+
deleteDocument,
|
|
35
|
+
getDocument,
|
|
36
|
+
listDocuments,
|
|
37
|
+
updateDocument,
|
|
38
|
+
} from "$lib/api/documents";
|
|
39
|
+
import {
|
|
40
|
+
createFolder,
|
|
41
|
+
deleteFolder,
|
|
42
|
+
listFolders,
|
|
43
|
+
updateFolder,
|
|
44
|
+
} from "$lib/api/folders";
|
|
45
|
+
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
|
46
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
47
|
+
import {
|
|
48
|
+
getDocRefreshNonce,
|
|
49
|
+
getSelectedTag,
|
|
50
|
+
refreshDocs,
|
|
51
|
+
} from "$lib/stores/tag-store.svelte";
|
|
52
|
+
import { cn } from "$lib/utils";
|
|
53
|
+
import { copyToClipboard } from "$lib/utils/clipboard.js";
|
|
54
|
+
|
|
55
|
+
// Rename/delete target shared by folders and documents in the tree.
|
|
56
|
+
type EntityKind = "folder" | "doc";
|
|
57
|
+
|
|
58
|
+
interface FolderItem {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type DndDoc = Document & { id: string };
|
|
64
|
+
|
|
65
|
+
const FLIP_MS = 200;
|
|
66
|
+
const FOLDER_EXPAND_DELAY_MS = 400;
|
|
67
|
+
|
|
68
|
+
let folders = $state<FolderItem[]>([]);
|
|
69
|
+
// Source of truth from the server. `rootItems` and `folderDocs` are the
|
|
70
|
+
// per-zone working copies that `svelte-dnd-action` mutates during a drag.
|
|
71
|
+
let documents = $state<DndDoc[]>([]);
|
|
72
|
+
let originalFolderByDoc = new Map<string, string | null>();
|
|
73
|
+
let expandedFolderIds = $state<Set<string>>(new Set());
|
|
74
|
+
let loadError = $state<string | null>(null);
|
|
75
|
+
let dragDisabled = $state(false);
|
|
76
|
+
// True while a drag is in flight across any zone. Folder auto-expand on
|
|
77
|
+
// hover should only fire during a drag, not on plain mouseover.
|
|
78
|
+
let isDraggingGlobal = $state(false);
|
|
79
|
+
|
|
80
|
+
let rootItems = $state<DndDoc[]>([]);
|
|
81
|
+
let folderDocsMap = $state<Record<string, DndDoc[]>>({});
|
|
82
|
+
|
|
83
|
+
let showNewFolderDialog = $state(false);
|
|
84
|
+
let newFolderName = $state("");
|
|
85
|
+
let newFolderError = $state<string | null>(null);
|
|
86
|
+
let newFolderSubmitting = $state(false);
|
|
87
|
+
|
|
88
|
+
// Rename dialog state (shared by folders and documents).
|
|
89
|
+
let showRenameDialog = $state(false);
|
|
90
|
+
let renameTarget = $state<{
|
|
91
|
+
kind: EntityKind;
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
} | null>(null);
|
|
95
|
+
let renameValue = $state("");
|
|
96
|
+
let renameError = $state<string | null>(null);
|
|
97
|
+
let renameSubmitting = $state(false);
|
|
98
|
+
|
|
99
|
+
// Delete confirmation state (shared by folders and documents).
|
|
100
|
+
let showDeleteDialog = $state(false);
|
|
101
|
+
let deleteTarget = $state<{
|
|
102
|
+
kind: EntityKind;
|
|
103
|
+
id: string;
|
|
104
|
+
name: string;
|
|
105
|
+
} | null>(null);
|
|
106
|
+
let deleteBusy = $state(false);
|
|
107
|
+
|
|
108
|
+
let expandTimer: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
+
let pendingExpandFolderId = $state<string | null>(null);
|
|
110
|
+
|
|
111
|
+
let copiedDocId = $state<string | null>(null);
|
|
112
|
+
let copyLoadingDocId = $state<string | null>(null);
|
|
113
|
+
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
114
|
+
|
|
115
|
+
async function handleCopyContent(docId: string) {
|
|
116
|
+
if (typeof window === "undefined") return;
|
|
117
|
+
// Copy the document's full markdown source. The list endpoint returns
|
|
118
|
+
// `content` truncated to 200 chars at the SQL level, so we fetch the
|
|
119
|
+
// single-document endpoint first to get the complete text. If the
|
|
120
|
+
// fetch fails we fall back to the list payload (excerpt, then
|
|
121
|
+
// truncated content) so the button still does something.
|
|
122
|
+
const cached = documents.find((d) => d.id === docId);
|
|
123
|
+
let text = "";
|
|
124
|
+
copyLoadingDocId = docId;
|
|
125
|
+
try {
|
|
126
|
+
const full = await getDocument(docId);
|
|
127
|
+
text = full.content ?? "";
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error("FolderTree: failed to fetch full document for copy", err);
|
|
130
|
+
text = cached?.excerpt ?? cached?.content ?? "";
|
|
131
|
+
} finally {
|
|
132
|
+
copyLoadingDocId = null;
|
|
133
|
+
}
|
|
134
|
+
if (!text) return;
|
|
135
|
+
const ok = await copyToClipboard(text);
|
|
136
|
+
if (!ok) return;
|
|
137
|
+
copiedDocId = docId;
|
|
138
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
139
|
+
copyTimer = setTimeout(() => {
|
|
140
|
+
copiedDocId = null;
|
|
141
|
+
copyTimer = null;
|
|
142
|
+
}, 2000);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clearExpandTimer() {
|
|
146
|
+
if (expandTimer !== null) {
|
|
147
|
+
clearTimeout(expandTimer);
|
|
148
|
+
expandTimer = null;
|
|
149
|
+
}
|
|
150
|
+
pendingExpandFolderId = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function scheduleFolderExpand(folderId: string) {
|
|
154
|
+
clearExpandTimer();
|
|
155
|
+
if (expandedFolderIds.has(folderId)) return;
|
|
156
|
+
pendingExpandFolderId = folderId;
|
|
157
|
+
expandTimer = setTimeout(() => {
|
|
158
|
+
if (
|
|
159
|
+
pendingExpandFolderId === folderId &&
|
|
160
|
+
!expandedFolderIds.has(folderId)
|
|
161
|
+
) {
|
|
162
|
+
const next = new Set(expandedFolderIds);
|
|
163
|
+
next.add(folderId);
|
|
164
|
+
expandedFolderIds = next;
|
|
165
|
+
}
|
|
166
|
+
expandTimer = null;
|
|
167
|
+
pendingExpandFolderId = null;
|
|
168
|
+
}, FOLDER_EXPAND_DELAY_MS);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sanitizeItems(raw: unknown): DndDoc[] {
|
|
172
|
+
if (!Array.isArray(raw)) return [];
|
|
173
|
+
return raw.filter(
|
|
174
|
+
(item): item is DndDoc =>
|
|
175
|
+
item !== null &&
|
|
176
|
+
typeof item === "object" &&
|
|
177
|
+
typeof (item as { id?: unknown }).id === "string",
|
|
178
|
+
) as DndDoc[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildZoneState(docs: DndDoc[]): {
|
|
182
|
+
root: DndDoc[];
|
|
183
|
+
byFolder: Record<string, DndDoc[]>;
|
|
184
|
+
} {
|
|
185
|
+
const root: DndDoc[] = [];
|
|
186
|
+
const byFolder: Record<string, DndDoc[]> = {};
|
|
187
|
+
for (const doc of docs) {
|
|
188
|
+
if (doc.folderId) {
|
|
189
|
+
const list = byFolder[doc.folderId] ?? [];
|
|
190
|
+
list.push(doc);
|
|
191
|
+
byFolder[doc.folderId] = list;
|
|
192
|
+
} else {
|
|
193
|
+
root.push(doc);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { root, byFolder };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resyncZonesFromDocuments() {
|
|
200
|
+
const { root, byFolder } = buildZoneState(documents);
|
|
201
|
+
rootItems = root;
|
|
202
|
+
folderDocsMap = byFolder;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function loadFolders() {
|
|
206
|
+
try {
|
|
207
|
+
const result = await listFolders(null);
|
|
208
|
+
folders = (result[0]?.children ?? []) as FolderItem[];
|
|
209
|
+
loadError = null;
|
|
210
|
+
} catch (e) {
|
|
211
|
+
console.error("FolderTree: failed to load folders", e);
|
|
212
|
+
loadError = "Failed to load folders";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function loadDocuments() {
|
|
217
|
+
try {
|
|
218
|
+
const tag = getSelectedTag();
|
|
219
|
+
const res = await listDocuments({ limit: 100, ...(tag ? { tag } : {}) });
|
|
220
|
+
documents = res.items as DndDoc[];
|
|
221
|
+
originalFolderByDoc = new Map(
|
|
222
|
+
documents.map((d) => [d.id, d.folderId ?? null]),
|
|
223
|
+
);
|
|
224
|
+
resyncZonesFromDocuments();
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error("FolderTree: failed to load documents", e);
|
|
227
|
+
loadError = "Failed to load documents";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function refresh() {
|
|
232
|
+
await Promise.all([loadFolders(), loadDocuments()]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
onMount(() => {
|
|
236
|
+
void refresh();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Re-fetch folders and documents whenever the global doc refresh nonce
|
|
240
|
+
// changes (e.g. after a dashboard import or another component calls
|
|
241
|
+
// refreshDocs()). Reading the nonce inside the effect registers it as
|
|
242
|
+
// a reactive dependency.
|
|
243
|
+
$effect(() => {
|
|
244
|
+
void getDocRefreshNonce();
|
|
245
|
+
// Re-filter the tree when the shared selected tag changes (from TagList).
|
|
246
|
+
void getSelectedTag();
|
|
247
|
+
void refresh();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
function toggleFolder(id: string) {
|
|
251
|
+
const wasExpanded = expandedFolderIds.has(id);
|
|
252
|
+
const next = new Set(expandedFolderIds);
|
|
253
|
+
if (wasExpanded) next.delete(id);
|
|
254
|
+
else next.add(id);
|
|
255
|
+
expandedFolderIds = next;
|
|
256
|
+
|
|
257
|
+
// When collapsing mid-drag, temporarily disable dnd so svelte-dnd-action
|
|
258
|
+
// does not try to measure zones that are about to be unmounted.
|
|
259
|
+
dragDisabled = true;
|
|
260
|
+
if (typeof window !== "undefined") {
|
|
261
|
+
window.setTimeout(() => {
|
|
262
|
+
dragDisabled = false;
|
|
263
|
+
}, FLIP_MS + 50);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function openNewFolderDialog() {
|
|
268
|
+
showNewFolderDialog = true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function closeNewFolderDialog() {
|
|
272
|
+
showNewFolderDialog = false;
|
|
273
|
+
newFolderName = "";
|
|
274
|
+
newFolderError = null;
|
|
275
|
+
newFolderSubmitting = false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function handleCreateFolder(e: Event) {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
newFolderError = null;
|
|
281
|
+
|
|
282
|
+
const trimmed = newFolderName.trim();
|
|
283
|
+
if (trimmed.length === 0) {
|
|
284
|
+
newFolderError = "Name is required";
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
newFolderSubmitting = true;
|
|
289
|
+
try {
|
|
290
|
+
await createFolder({ name: trimmed, parentId: null });
|
|
291
|
+
closeNewFolderDialog();
|
|
292
|
+
await loadFolders();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error("FolderTree: createFolder failed", err);
|
|
295
|
+
newFolderError = err instanceof Error ? err.message : m.error_generic();
|
|
296
|
+
} finally {
|
|
297
|
+
newFolderSubmitting = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function setZoneItems(zoneFolderId: string | null, next: DndDoc[]) {
|
|
302
|
+
if (zoneFolderId === null) {
|
|
303
|
+
rootItems = next;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
folderDocsMap = { ...folderDocsMap, [zoneFolderId]: next };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function handleConsider(zoneFolderId: string | null) {
|
|
310
|
+
return (e: CustomEvent<DndEvent<DndDoc>>) => {
|
|
311
|
+
isDraggingGlobal = true;
|
|
312
|
+
const next = sanitizeItems(e.detail.items);
|
|
313
|
+
setZoneItems(zoneFolderId, next);
|
|
314
|
+
clearExpandTimer();
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handleFinalize(zoneFolderId: string | null) {
|
|
319
|
+
return (e: CustomEvent<DndEvent<DndDoc>>) => {
|
|
320
|
+
const next = sanitizeItems(e.detail.items);
|
|
321
|
+
setZoneItems(zoneFolderId, next);
|
|
322
|
+
clearExpandTimer();
|
|
323
|
+
void persistZoneChanges(zoneFolderId, next);
|
|
324
|
+
isDraggingGlobal = false;
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function persistZoneChanges(
|
|
329
|
+
zoneFolderId: string | null,
|
|
330
|
+
zoneItems: DndDoc[],
|
|
331
|
+
) {
|
|
332
|
+
const target: string | null = zoneFolderId;
|
|
333
|
+
const updates: Array<{ id: string; folderId: string | null }> = [];
|
|
334
|
+
for (const item of zoneItems) {
|
|
335
|
+
const original = originalFolderByDoc.get(item.id);
|
|
336
|
+
if (original === undefined) continue;
|
|
337
|
+
const current = item.folderId ?? null;
|
|
338
|
+
if (current !== target || (target !== null && current !== target)) {
|
|
339
|
+
updates.push({ id: item.id, folderId: target });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (updates.length === 0) return;
|
|
343
|
+
try {
|
|
344
|
+
await Promise.all(
|
|
345
|
+
updates.map((u) => updateDocument(u.id, { folderId: u.folderId })),
|
|
346
|
+
);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.error("FolderTree: persist failed", err);
|
|
349
|
+
} finally {
|
|
350
|
+
await refresh();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- Rename / delete (folders and documents) ---
|
|
355
|
+
function startRename(kind: EntityKind, id: string, name: string) {
|
|
356
|
+
renameTarget = { kind, id, name };
|
|
357
|
+
renameValue = name;
|
|
358
|
+
renameError = null;
|
|
359
|
+
showRenameDialog = true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function closeRenameDialog() {
|
|
363
|
+
showRenameDialog = false;
|
|
364
|
+
renameTarget = null;
|
|
365
|
+
renameValue = "";
|
|
366
|
+
renameError = null;
|
|
367
|
+
renameSubmitting = false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function submitRename(e?: Event) {
|
|
371
|
+
e?.preventDefault();
|
|
372
|
+
const target = renameTarget;
|
|
373
|
+
if (!target) return;
|
|
374
|
+
const trimmed = renameValue.trim();
|
|
375
|
+
if (trimmed.length === 0) {
|
|
376
|
+
renameError = "Name is required";
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
renameSubmitting = true;
|
|
380
|
+
try {
|
|
381
|
+
if (target.kind === "folder") {
|
|
382
|
+
await updateFolder(target.id, { name: trimmed });
|
|
383
|
+
} else {
|
|
384
|
+
await updateDocument(target.id, { title: trimmed });
|
|
385
|
+
}
|
|
386
|
+
closeRenameDialog();
|
|
387
|
+
await refresh();
|
|
388
|
+
// Notify the other sidebar lists (RecentDocs) to refetch.
|
|
389
|
+
refreshDocs();
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error("FolderTree: rename failed", err);
|
|
392
|
+
renameError = err instanceof Error ? err.message : m.error_generic();
|
|
393
|
+
} finally {
|
|
394
|
+
renameSubmitting = false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function startDelete(kind: EntityKind, id: string, name: string) {
|
|
399
|
+
deleteTarget = { kind, id, name };
|
|
400
|
+
showDeleteDialog = true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function cancelDelete() {
|
|
404
|
+
showDeleteDialog = false;
|
|
405
|
+
deleteTarget = null;
|
|
406
|
+
deleteBusy = false;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function confirmDelete() {
|
|
410
|
+
const target = deleteTarget;
|
|
411
|
+
if (!target || deleteBusy) return;
|
|
412
|
+
deleteBusy = true;
|
|
413
|
+
try {
|
|
414
|
+
if (target.kind === "folder") {
|
|
415
|
+
// Deleting a folder moves its documents back to the root: the
|
|
416
|
+
// documents.folder_id foreign key is ON DELETE SET NULL, so the
|
|
417
|
+
// documents survive and reappear at the top level.
|
|
418
|
+
await deleteFolder(target.id);
|
|
419
|
+
} else {
|
|
420
|
+
await deleteDocument(target.id);
|
|
421
|
+
}
|
|
422
|
+
cancelDelete();
|
|
423
|
+
await refresh();
|
|
424
|
+
refreshDocs();
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.error("FolderTree: delete failed", err);
|
|
427
|
+
loadError = err instanceof Error ? err.message : m.error_generic();
|
|
428
|
+
deleteBusy = false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
</script>
|
|
432
|
+
|
|
433
|
+
{#snippet docMenu(doc: DndDoc)}
|
|
434
|
+
<DropdownMenu>
|
|
435
|
+
<DropdownMenuTrigger>
|
|
436
|
+
{#snippet child({ props })}
|
|
437
|
+
<button
|
|
438
|
+
{...props}
|
|
439
|
+
type="button"
|
|
440
|
+
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"
|
|
441
|
+
aria-label={m.editor_more_options()}
|
|
442
|
+
title={m.editor_more_options()}
|
|
443
|
+
onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}
|
|
444
|
+
>
|
|
445
|
+
<MoreVertical class="size-3.5" />
|
|
446
|
+
</button>
|
|
447
|
+
{/snippet}
|
|
448
|
+
</DropdownMenuTrigger>
|
|
449
|
+
<DropdownMenuContent align="end">
|
|
450
|
+
<DropdownMenuItem onSelect={() => startRename("doc", doc.id, doc.title)}>
|
|
451
|
+
{m.folders_rename()}
|
|
452
|
+
</DropdownMenuItem>
|
|
453
|
+
<DropdownMenuItem
|
|
454
|
+
class="text-destructive focus:text-destructive"
|
|
455
|
+
onSelect={() => startDelete("doc", doc.id, doc.title)}
|
|
456
|
+
>
|
|
457
|
+
{m.action_delete()}
|
|
458
|
+
</DropdownMenuItem>
|
|
459
|
+
</DropdownMenuContent>
|
|
460
|
+
</DropdownMenu>
|
|
461
|
+
{/snippet}
|
|
462
|
+
|
|
463
|
+
<div class="space-y-1">
|
|
464
|
+
<a
|
|
465
|
+
href="/"
|
|
466
|
+
class="mb-2 block px-2 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
467
|
+
title={m.dashboard_title()}
|
|
468
|
+
>{m.sidebar_folders()}</a>
|
|
469
|
+
{#if loadError}
|
|
470
|
+
<p class="px-2 text-xs text-destructive">{loadError}</p>
|
|
471
|
+
{/if}
|
|
472
|
+
|
|
473
|
+
<div
|
|
474
|
+
class="min-h-[8px] space-y-0.5"
|
|
475
|
+
use:dndzone={{ items: rootItems, flipDurationMs: FLIP_MS, type: "doc", dropTargetStyle: {}, dragDisabled }}
|
|
476
|
+
onconsider={handleConsider(null)}
|
|
477
|
+
onfinalize={handleFinalize(null)}
|
|
478
|
+
>
|
|
479
|
+
{#each rootItems as doc (doc.id)}
|
|
480
|
+
<div animate:flip={{ duration: FLIP_MS }} class="group/doc flex w-full min-w-0 items-center gap-1">
|
|
481
|
+
<a
|
|
482
|
+
href={`/docs/${doc.id}`}
|
|
483
|
+
data-sveltekit-noscroll
|
|
484
|
+
class={cn(
|
|
485
|
+
"flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
486
|
+
page.params.id === doc.id && "bg-accent text-accent-foreground font-medium"
|
|
487
|
+
)}
|
|
488
|
+
>
|
|
489
|
+
<span class="w-3.5 shrink-0"></span>
|
|
490
|
+
<FileText class="size-4 shrink-0 text-muted-foreground" />
|
|
491
|
+
<span class="min-w-0 truncate">{doc.title}</span>
|
|
492
|
+
</a>
|
|
493
|
+
<button
|
|
494
|
+
type="button"
|
|
495
|
+
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' : ''}"
|
|
496
|
+
aria-label={m.action_copy_content()}
|
|
497
|
+
title={m.action_copy_content()}
|
|
498
|
+
disabled={copyLoadingDocId === doc.id}
|
|
499
|
+
onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); void handleCopyContent(doc.id); }}
|
|
500
|
+
>
|
|
501
|
+
{#if copyLoadingDocId === doc.id}
|
|
502
|
+
<Loader2 class="size-3.5 animate-spin" />
|
|
503
|
+
{:else if copiedDocId === doc.id}
|
|
504
|
+
<Check class="size-3.5" />
|
|
505
|
+
{:else}
|
|
506
|
+
<Copy class="size-3.5" />
|
|
507
|
+
{/if}
|
|
508
|
+
</button>
|
|
509
|
+
{@render docMenu(doc)}
|
|
510
|
+
</div>
|
|
511
|
+
{/each}
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
{#each folders as folder (folder.id)}
|
|
515
|
+
{@const isExpanded = expandedFolderIds.has(folder.id)}
|
|
516
|
+
{@const folderDocs = folderDocsMap[folder.id] ?? []}
|
|
517
|
+
<div
|
|
518
|
+
role="group"
|
|
519
|
+
aria-label={folder.name}
|
|
520
|
+
onmouseenter={() => {
|
|
521
|
+
if (isDraggingGlobal && !expandedFolderIds.has(folder.id))
|
|
522
|
+
scheduleFolderExpand(folder.id);
|
|
523
|
+
}}
|
|
524
|
+
onmouseleave={() => {
|
|
525
|
+
if (isDraggingGlobal) clearExpandTimer();
|
|
526
|
+
}}
|
|
527
|
+
>
|
|
528
|
+
<div class="group/folder flex w-full min-w-0 items-center gap-1">
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
onclick={() => toggleFolder(folder.id)}
|
|
532
|
+
aria-expanded={isExpanded}
|
|
533
|
+
class={cn(
|
|
534
|
+
"flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
535
|
+
page.params.id === folder.id && "bg-accent text-accent-foreground font-medium"
|
|
536
|
+
)}
|
|
537
|
+
>
|
|
538
|
+
<ChevronRight class={cn("size-3.5 shrink-0 transition-transform", isExpanded && "rotate-90")} />
|
|
539
|
+
<Folder class="size-4 shrink-0 text-muted-foreground" />
|
|
540
|
+
<span class="min-w-0 truncate">{folder.name}</span>
|
|
541
|
+
</button>
|
|
542
|
+
<DropdownMenu>
|
|
543
|
+
<DropdownMenuTrigger>
|
|
544
|
+
{#snippet child({ props })}
|
|
545
|
+
<button
|
|
546
|
+
{...props}
|
|
547
|
+
type="button"
|
|
548
|
+
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/folder:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
549
|
+
aria-label={m.editor_more_options()}
|
|
550
|
+
title={m.editor_more_options()}
|
|
551
|
+
>
|
|
552
|
+
<MoreVertical class="size-3.5" />
|
|
553
|
+
</button>
|
|
554
|
+
{/snippet}
|
|
555
|
+
</DropdownMenuTrigger>
|
|
556
|
+
<DropdownMenuContent align="end">
|
|
557
|
+
<DropdownMenuItem onSelect={() => startRename("folder", folder.id, folder.name)}>
|
|
558
|
+
{m.folders_rename()}
|
|
559
|
+
</DropdownMenuItem>
|
|
560
|
+
<DropdownMenuItem
|
|
561
|
+
class="text-destructive focus:text-destructive"
|
|
562
|
+
onSelect={() => startDelete("folder", folder.id, folder.name)}
|
|
563
|
+
>
|
|
564
|
+
{m.folders_delete()}
|
|
565
|
+
</DropdownMenuItem>
|
|
566
|
+
</DropdownMenuContent>
|
|
567
|
+
</DropdownMenu>
|
|
568
|
+
</div>
|
|
569
|
+
{#if isExpanded}
|
|
570
|
+
<div class="ml-4 border-l border-border pl-1">
|
|
571
|
+
<div
|
|
572
|
+
class="min-h-[8px] space-y-0.5"
|
|
573
|
+
use:dndzone={{ items: folderDocs, flipDurationMs: FLIP_MS, type: "doc", dropTargetStyle: {}, dragDisabled }}
|
|
574
|
+
onconsider={handleConsider(folder.id)}
|
|
575
|
+
onfinalize={handleFinalize(folder.id)}
|
|
576
|
+
>
|
|
577
|
+
{#each folderDocs as doc (doc.id)}
|
|
578
|
+
<div animate:flip={{ duration: FLIP_MS }} class="group/doc flex w-full min-w-0 items-center gap-1">
|
|
579
|
+
<a
|
|
580
|
+
href={`/docs/${doc.id}`}
|
|
581
|
+
data-sveltekit-noscroll
|
|
582
|
+
class={cn(
|
|
583
|
+
"flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
584
|
+
page.params.id === doc.id && "bg-accent text-accent-foreground font-medium"
|
|
585
|
+
)}
|
|
586
|
+
>
|
|
587
|
+
<span class="w-3.5 shrink-0"></span>
|
|
588
|
+
<FileText class="size-4 shrink-0 text-muted-foreground" />
|
|
589
|
+
<span class="min-w-0 truncate">{doc.title}</span>
|
|
590
|
+
</a>
|
|
591
|
+
<button
|
|
592
|
+
type="button"
|
|
593
|
+
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' : ''}"
|
|
594
|
+
aria-label={m.action_copy_content()}
|
|
595
|
+
title={m.action_copy_content()}
|
|
596
|
+
disabled={copyLoadingDocId === doc.id}
|
|
597
|
+
onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); void handleCopyContent(doc.id); }}
|
|
598
|
+
>
|
|
599
|
+
{#if copyLoadingDocId === doc.id}
|
|
600
|
+
<Loader2 class="size-3.5 animate-spin" />
|
|
601
|
+
{:else if copiedDocId === doc.id}
|
|
602
|
+
<Check class="size-3.5" />
|
|
603
|
+
{:else}
|
|
604
|
+
<Copy class="size-3.5" />
|
|
605
|
+
{/if}
|
|
606
|
+
</button>
|
|
607
|
+
{@render docMenu(doc)}
|
|
608
|
+
</div>
|
|
609
|
+
{/each}
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
{/if}
|
|
613
|
+
</div>
|
|
614
|
+
{/each}
|
|
615
|
+
|
|
616
|
+
<button
|
|
617
|
+
type="button"
|
|
618
|
+
onclick={openNewFolderDialog}
|
|
619
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
620
|
+
>
|
|
621
|
+
<Plus class="size-3.5" />
|
|
622
|
+
<span>{m.folders_new()}</span>
|
|
623
|
+
</button>
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
<Dialog bind:open={showNewFolderDialog} onOpenChange={(next) => { if (!next) closeNewFolderDialog(); }}>
|
|
627
|
+
<DialogHeader>
|
|
628
|
+
<DialogTitle>{m.folders_new()}</DialogTitle>
|
|
629
|
+
<DialogDescription>{m.folders_name_placeholder()}</DialogDescription>
|
|
630
|
+
</DialogHeader>
|
|
631
|
+
|
|
632
|
+
<form onsubmit={handleCreateFolder} class="space-y-4">
|
|
633
|
+
<div class="space-y-2">
|
|
634
|
+
<Label for="new-folder-name">{m.folders_name_placeholder()}</Label>
|
|
635
|
+
<Input
|
|
636
|
+
id="new-folder-name"
|
|
637
|
+
name="name"
|
|
638
|
+
type="text"
|
|
639
|
+
bind:value={newFolderName}
|
|
640
|
+
placeholder={m.folders_name_placeholder()}
|
|
641
|
+
maxlength={50}
|
|
642
|
+
required
|
|
643
|
+
disabled={newFolderSubmitting}
|
|
644
|
+
aria-invalid={newFolderError ? "true" : undefined}
|
|
645
|
+
aria-describedby={newFolderError ? "new-folder-name-error" : undefined}
|
|
646
|
+
autocomplete="off"
|
|
647
|
+
/>
|
|
648
|
+
{#if newFolderError}
|
|
649
|
+
<p id="new-folder-name-error" class="text-xs text-destructive" role="alert">{newFolderError}</p>
|
|
650
|
+
{/if}
|
|
651
|
+
</div>
|
|
652
|
+
</form>
|
|
653
|
+
|
|
654
|
+
<DialogFooter>
|
|
655
|
+
<Button
|
|
656
|
+
variant="outline"
|
|
657
|
+
type="button"
|
|
658
|
+
onclick={closeNewFolderDialog}
|
|
659
|
+
disabled={newFolderSubmitting}
|
|
660
|
+
>
|
|
661
|
+
{m.action_cancel()}
|
|
662
|
+
</Button>
|
|
663
|
+
<Button
|
|
664
|
+
type="submit"
|
|
665
|
+
onclick={handleCreateFolder}
|
|
666
|
+
disabled={newFolderSubmitting || newFolderName.trim().length === 0}
|
|
667
|
+
>
|
|
668
|
+
{newFolderSubmitting ? m.action_loading() : m.action_create()}
|
|
669
|
+
</Button>
|
|
670
|
+
</DialogFooter>
|
|
671
|
+
</Dialog>
|
|
672
|
+
|
|
673
|
+
<!-- Rename dialog (folders and documents) -->
|
|
674
|
+
<Dialog bind:open={showRenameDialog} onOpenChange={(next) => { if (!next) closeRenameDialog(); }}>
|
|
675
|
+
<DialogHeader>
|
|
676
|
+
<DialogTitle>{m.folders_rename()}</DialogTitle>
|
|
677
|
+
<DialogDescription>
|
|
678
|
+
{renameTarget?.kind === "folder" ? m.folders_name_placeholder() : m.doc_title_label()}
|
|
679
|
+
</DialogDescription>
|
|
680
|
+
</DialogHeader>
|
|
681
|
+
|
|
682
|
+
<form onsubmit={submitRename} class="space-y-4">
|
|
683
|
+
<div class="space-y-2">
|
|
684
|
+
<Label for="rename-input">
|
|
685
|
+
{renameTarget?.kind === "folder" ? m.folders_name_placeholder() : m.doc_title_label()}
|
|
686
|
+
</Label>
|
|
687
|
+
<Input
|
|
688
|
+
id="rename-input"
|
|
689
|
+
name="name"
|
|
690
|
+
type="text"
|
|
691
|
+
bind:value={renameValue}
|
|
692
|
+
maxlength={255}
|
|
693
|
+
required
|
|
694
|
+
disabled={renameSubmitting}
|
|
695
|
+
aria-invalid={renameError ? "true" : undefined}
|
|
696
|
+
aria-describedby={renameError ? "rename-input-error" : undefined}
|
|
697
|
+
autocomplete="off"
|
|
698
|
+
/>
|
|
699
|
+
{#if renameError}
|
|
700
|
+
<p id="rename-input-error" class="text-xs text-destructive" role="alert">{renameError}</p>
|
|
701
|
+
{/if}
|
|
702
|
+
</div>
|
|
703
|
+
</form>
|
|
704
|
+
|
|
705
|
+
<DialogFooter>
|
|
706
|
+
<Button variant="outline" type="button" onclick={closeRenameDialog} disabled={renameSubmitting}>
|
|
707
|
+
{m.action_cancel()}
|
|
708
|
+
</Button>
|
|
709
|
+
<Button
|
|
710
|
+
type="submit"
|
|
711
|
+
onclick={submitRename}
|
|
712
|
+
disabled={renameSubmitting || renameValue.trim().length === 0}
|
|
713
|
+
>
|
|
714
|
+
{renameSubmitting ? m.action_loading() : m.action_save()}
|
|
715
|
+
</Button>
|
|
716
|
+
</DialogFooter>
|
|
717
|
+
</Dialog>
|
|
718
|
+
|
|
719
|
+
<!-- Delete confirmation (folders and documents) -->
|
|
720
|
+
<ConfirmDialog
|
|
721
|
+
bind:open={showDeleteDialog}
|
|
722
|
+
title={deleteTarget?.kind === "folder" ? m.folders_delete_title() : m.doc_delete()}
|
|
723
|
+
description={deleteTarget?.kind === "folder"
|
|
724
|
+
? "Delete this folder? Its documents will be moved to the root and will not be deleted."
|
|
725
|
+
: m.doc_delete_confirm()}
|
|
726
|
+
confirmLabel={m.action_delete()}
|
|
727
|
+
variant="destructive"
|
|
728
|
+
busy={deleteBusy}
|
|
729
|
+
onConfirm={confirmDelete}
|
|
730
|
+
onCancel={cancelDelete}
|
|
731
|
+
/>
|