@hiai-gg/docsmint 0.3.0
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/LICENSE +171 -0
- package/README.md +348 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/redis-factory.ts +40 -0
- package/backend/src/lib/storage-factory.ts +56 -0
- package/frontend/src/lib/components/editor/shared-document.ts +237 -0
- package/frontend/src/lib/extensions/context.ts +60 -0
- package/frontend/src/lib/extensions/doc-tabs.ts +18 -0
- package/frontend/src/lib/extensions/resolve.ts +48 -0
- package/frontend/src/lib/extensions/types.ts +202 -0
- package/frontend/src/lib/hosts/DocsmintSharedDocumentHost.svelte +65 -0
- package/frontend/src/lib/hosts/HiaiDocsDashboardHost.svelte +1007 -0
- package/frontend/src/lib/hosts/HiaiDocsExtensionProvider.svelte +20 -0
- package/frontend/src/lib/hosts/HiaiDocsSearchHost.svelte +996 -0
- package/frontend/src/lib/hosts/index.ts +25 -0
- package/frontend/src/lib/index.ts +65 -0
- package/frontend/src/lib/stores/doc-tab-registry.svelte.ts +68 -0
- package/package.json +178 -0
- package/packages/cli/src/client.ts +271 -0
- package/packages/cli/src/commands/config.ts +47 -0
- package/packages/cli/src/commands/create.ts +35 -0
- package/packages/cli/src/commands/delete.ts +37 -0
- package/packages/cli/src/commands/export.ts +36 -0
- package/packages/cli/src/commands/folders.ts +88 -0
- package/packages/cli/src/commands/history.ts +55 -0
- package/packages/cli/src/commands/list.ts +61 -0
- package/packages/cli/src/commands/read.ts +38 -0
- package/packages/cli/src/commands/restore.ts +30 -0
- package/packages/cli/src/commands/search.ts +56 -0
- package/packages/cli/src/commands/snapshot.ts +35 -0
- package/packages/cli/src/commands/update.ts +54 -0
- package/packages/cli/src/config.ts +83 -0
- package/packages/cli/src/format.ts +153 -0
- package/packages/cli/src/index.ts +73 -0
- package/packages/db/src/client.ts +20 -0
- package/packages/db/src/index.ts +5 -0
- package/packages/db/src/schema.ts +692 -0
- package/packages/db/src/with-tenant.ts +75 -0
- package/packages/mcp-server/src/client.ts +172 -0
- package/packages/mcp-server/src/index.ts +109 -0
- package/packages/mcp-server/src/tools/create-document.ts +32 -0
- package/packages/mcp-server/src/tools/create-folder.ts +24 -0
- package/packages/mcp-server/src/tools/create-snapshot.ts +30 -0
- package/packages/mcp-server/src/tools/export-document.ts +22 -0
- package/packages/mcp-server/src/tools/get-document.ts +20 -0
- package/packages/mcp-server/src/tools/list-documents.ts +42 -0
- package/packages/mcp-server/src/tools/list-folders.ts +25 -0
- package/packages/mcp-server/src/tools/search.ts +42 -0
- package/packages/mcp-server/src/tools/update-document.ts +30 -0
- package/packages/mcp-server/src/tools/version-history.ts +32 -0
- package/packages/mcp-server/src/types.ts +126 -0
- package/packages/sdk/dist/client.d.ts +187 -0
- package/packages/sdk/dist/client.js +568 -0
- package/packages/sdk/dist/index.d.ts +3 -0
- package/packages/sdk/dist/index.js +1 -0
- package/packages/sdk/dist/types.d.ts +391 -0
- package/packages/sdk/dist/types.js +8 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge } from "@hiai-gg/hiai-ui/components/ui/badge";
|
|
3
|
+
import { Button } from "@hiai-gg/hiai-ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
|
|
10
|
+
import { Label } from "@hiai-gg/hiai-ui/components/ui/label";
|
|
11
|
+
import SelectRoot from "@hiai-gg/hiai-ui/components/ui/select/select.svelte";
|
|
12
|
+
import SelectContent from "@hiai-gg/hiai-ui/components/ui/select/select-content.svelte";
|
|
13
|
+
import SelectItem from "@hiai-gg/hiai-ui/components/ui/select/select-item.svelte";
|
|
14
|
+
import SelectTrigger from "@hiai-gg/hiai-ui/components/ui/select/select-trigger.svelte";
|
|
15
|
+
import SelectValue from "@hiai-gg/hiai-ui/components/ui/select/select-value.svelte";
|
|
16
|
+
import {
|
|
17
|
+
ArrowLeft,
|
|
18
|
+
Calendar,
|
|
19
|
+
Check,
|
|
20
|
+
ChevronDown,
|
|
21
|
+
ChevronRight,
|
|
22
|
+
Clock,
|
|
23
|
+
Copy,
|
|
24
|
+
FileText,
|
|
25
|
+
Folder,
|
|
26
|
+
FolderKanban,
|
|
27
|
+
FolderOpen,
|
|
28
|
+
LayoutDashboard,
|
|
29
|
+
Loader2,
|
|
30
|
+
MoreVertical,
|
|
31
|
+
Plus,
|
|
32
|
+
RotateCcw,
|
|
33
|
+
Share2,
|
|
34
|
+
Tag,
|
|
35
|
+
Upload,
|
|
36
|
+
X,
|
|
37
|
+
} from "lucide-svelte";
|
|
38
|
+
|
|
39
|
+
const Select = {
|
|
40
|
+
Root: SelectRoot,
|
|
41
|
+
Content: SelectContent,
|
|
42
|
+
Item: SelectItem,
|
|
43
|
+
Trigger: SelectTrigger,
|
|
44
|
+
Value: SelectValue,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
import { ConfirmDialog } from "@hiai-gg/hiai-ui/components/ui/confirm-dialog";
|
|
48
|
+
import { goto, invalidateAll } from "$app/navigation";
|
|
49
|
+
import { page } from "$app/state";
|
|
50
|
+
import type { Category } from "$lib/api/categories";
|
|
51
|
+
import { apiFetch } from "$lib/api/client";
|
|
52
|
+
import { createDocument, listDocuments } from "$lib/api/documents";
|
|
53
|
+
import { createFolder, duplicateDocument, listFolders } from "$lib/api/folders";
|
|
54
|
+
import type { Tag as ApiTag } from "$lib/api/tags";
|
|
55
|
+
import DatePicker from "$lib/components/DatePicker.svelte";
|
|
56
|
+
import DocumentCard from "$lib/components/DocumentCard.svelte";
|
|
57
|
+
import FolderCard from "$lib/components/FolderCard.svelte";
|
|
58
|
+
import FolderDialog from "$lib/components/FolderDialog.svelte";
|
|
59
|
+
import ImportProgress, {
|
|
60
|
+
type ImportItem,
|
|
61
|
+
} from "$lib/components/ImportProgress.svelte";
|
|
62
|
+
import ShareDialog from "$lib/components/ShareDialog.svelte";
|
|
63
|
+
import { getFrontendExtensions } from "$lib/extensions/context";
|
|
64
|
+
import { resolveExtensions } from "$lib/extensions/resolve";
|
|
65
|
+
import type { ExtensionVisibilityContext } from "$lib/extensions/types";
|
|
66
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
67
|
+
import { refreshFolders } from "$lib/stores/subfolders-refresh-store.svelte.js";
|
|
68
|
+
import { refreshDocs } from "$lib/stores/tag-store.svelte.js";
|
|
69
|
+
import type { Document, Folder as FolderType } from "$lib/types.js";
|
|
70
|
+
|
|
71
|
+
export interface HiaiDocsDashboardData {
|
|
72
|
+
categories: Category[];
|
|
73
|
+
tags: ApiTag[];
|
|
74
|
+
activeFolder: FolderType | null;
|
|
75
|
+
breadcrumb: Array<{ id: string; name: string }>;
|
|
76
|
+
rootFolders: FolderType[];
|
|
77
|
+
recentDocs: Document[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { data, extensionContext = { pathname: "/" } } = $props<{
|
|
81
|
+
data: HiaiDocsDashboardData;
|
|
82
|
+
extensionContext?: ExtensionVisibilityContext;
|
|
83
|
+
}>();
|
|
84
|
+
|
|
85
|
+
const frontendExtensions = getFrontendExtensions();
|
|
86
|
+
const dashboardWidgets = $derived.by(() =>
|
|
87
|
+
resolveExtensions(frontendExtensions.dashboardWidgets, extensionContext),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// --- Query parameters via SvelteKit page state ---
|
|
91
|
+
const activeFolderId = $derived(page.url.searchParams.get("folder") || null);
|
|
92
|
+
const activeCategoryId = $derived(
|
|
93
|
+
page.url.searchParams.get("category") || null,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// --- Active Filter Inputs (Local State) ---
|
|
97
|
+
let searchQuery = $state("");
|
|
98
|
+
let selectedTagId = $state<string | null>(null);
|
|
99
|
+
let dateFrom = $state("");
|
|
100
|
+
let dateTo = $state("");
|
|
101
|
+
|
|
102
|
+
// Sync inputs with URL params if they exist
|
|
103
|
+
$effect(() => {
|
|
104
|
+
searchQuery = page.url.searchParams.get("q") ?? "";
|
|
105
|
+
selectedTagId = page.url.searchParams.get("tag") ?? null;
|
|
106
|
+
dateFrom = page.url.searchParams.get("dateFrom") ?? "";
|
|
107
|
+
dateTo = page.url.searchParams.get("dateTo") ?? "";
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- Dialog states ---
|
|
111
|
+
let showFolderDialog = $state(false);
|
|
112
|
+
let folderDialogMode = $state<"create" | "edit">("create");
|
|
113
|
+
let folderDialogTarget = $state<{ id: string; name: string } | null>(null);
|
|
114
|
+
|
|
115
|
+
let showDeleteFolderDialog = $state(false);
|
|
116
|
+
let deleteFolderTargetId = $state<string | null>(null);
|
|
117
|
+
let deleteFolderBusy = $state(false);
|
|
118
|
+
|
|
119
|
+
let showShareDialog = $state(false);
|
|
120
|
+
type ShareTarget =
|
|
121
|
+
| { kind: "document"; documentId: string; title: string }
|
|
122
|
+
| { kind: "folder"; folderId: string; name: string }
|
|
123
|
+
| { kind: "category"; categoryId: string; name: string };
|
|
124
|
+
let shareTarget = $state<ShareTarget | null>(null);
|
|
125
|
+
let importOpen = $state(false);
|
|
126
|
+
let importItems = $state<ImportItem[]>([]);
|
|
127
|
+
let importInput = $state<HTMLInputElement | undefined>(undefined);
|
|
128
|
+
let duplicatingDocumentId = $state<string | null>(null);
|
|
129
|
+
let optimisticDocuments = $state<Document[]>([]);
|
|
130
|
+
|
|
131
|
+
function openShareDialogForDocument(id: string, title: string) {
|
|
132
|
+
shareTarget = { kind: "document", documentId: id, title };
|
|
133
|
+
showShareDialog = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function openShareDialogForFolder(id: string, name: string) {
|
|
137
|
+
shareTarget = { kind: "folder", folderId: id, name };
|
|
138
|
+
showShareDialog = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function openShareDialogForCategory(id: string, name: string) {
|
|
142
|
+
shareTarget = { kind: "category", categoryId: id, name };
|
|
143
|
+
showShareDialog = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Mutating actions ---
|
|
147
|
+
function handleNewDocument() {
|
|
148
|
+
let url = "/docs/new";
|
|
149
|
+
const params = new URLSearchParams();
|
|
150
|
+
if (activeFolderId) params.set("folder", activeFolderId);
|
|
151
|
+
if (activeCategoryId) params.set("category", activeCategoryId);
|
|
152
|
+
const qs = params.toString();
|
|
153
|
+
if (qs) url += `?${qs}`;
|
|
154
|
+
goto(url);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function handleRenameFolder(id: string) {
|
|
158
|
+
const folder = activeFolderId
|
|
159
|
+
? data.activeFolder?.children?.find((c: FolderType) => c.id === id)
|
|
160
|
+
: data.rootFolders.find((f: FolderType) => f.id === id);
|
|
161
|
+
if (!folder) return;
|
|
162
|
+
folderDialogMode = "edit";
|
|
163
|
+
folderDialogTarget = { id: folder.id, name: folder.name };
|
|
164
|
+
showFolderDialog = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function saveFolder(name: string) {
|
|
168
|
+
if (folderDialogMode === "create") {
|
|
169
|
+
await apiFetch("/api/folders", {
|
|
170
|
+
method: "POST",
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
name,
|
|
173
|
+
parentId: activeFolderId || null,
|
|
174
|
+
categoryId: activeFolderId ? null : activeCategoryId || null,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
} else if (folderDialogMode === "edit" && folderDialogTarget) {
|
|
178
|
+
await apiFetch(`/api/folders/${folderDialogTarget.id}`, {
|
|
179
|
+
method: "PATCH",
|
|
180
|
+
body: JSON.stringify({ name }),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
showFolderDialog = false;
|
|
184
|
+
refreshFolders();
|
|
185
|
+
await invalidateAll();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleDeleteFolder(id: string) {
|
|
189
|
+
deleteFolderTargetId = id;
|
|
190
|
+
showDeleteFolderDialog = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function confirmDeleteFolder() {
|
|
194
|
+
const id = deleteFolderTargetId;
|
|
195
|
+
if (!id || deleteFolderBusy) return;
|
|
196
|
+
deleteFolderBusy = true;
|
|
197
|
+
try {
|
|
198
|
+
await apiFetch(`/api/folders/${id}`, { method: "DELETE" });
|
|
199
|
+
showDeleteFolderDialog = false;
|
|
200
|
+
deleteFolderTargetId = null;
|
|
201
|
+
await invalidateAll();
|
|
202
|
+
refreshFolders();
|
|
203
|
+
refreshDocs();
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error("Failed to delete folder", e);
|
|
206
|
+
} finally {
|
|
207
|
+
deleteFolderBusy = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function handleDeleteDocument(id: string) {
|
|
212
|
+
if (!confirm(`${m.action_delete()}?`)) return;
|
|
213
|
+
try {
|
|
214
|
+
await apiFetch(`/api/documents/${id}`, { method: "DELETE" });
|
|
215
|
+
await invalidateAll();
|
|
216
|
+
refreshDocs();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
console.error("Failed to delete document", e);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function handleDuplicateDocument(id: string) {
|
|
223
|
+
if (duplicatingDocumentId) return;
|
|
224
|
+
duplicatingDocumentId = id;
|
|
225
|
+
try {
|
|
226
|
+
const duplicate = await duplicateDocument(id);
|
|
227
|
+
optimisticDocuments = [
|
|
228
|
+
...optimisticDocuments.filter((doc) => doc.id !== duplicate.id),
|
|
229
|
+
duplicate,
|
|
230
|
+
];
|
|
231
|
+
await invalidateAll();
|
|
232
|
+
const currentDocuments = data.activeFolder?.documents ?? data.recentDocs;
|
|
233
|
+
optimisticDocuments = optimisticDocuments.filter(
|
|
234
|
+
(doc) =>
|
|
235
|
+
!currentDocuments.some((current: Document) => current.id === doc.id),
|
|
236
|
+
);
|
|
237
|
+
refreshDocs();
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error("Failed to duplicate document", e);
|
|
240
|
+
} finally {
|
|
241
|
+
duplicatingDocumentId = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- Import functions ---
|
|
246
|
+
function triggerImport() {
|
|
247
|
+
importInput?.click();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function handleImportFile(e: Event) {
|
|
251
|
+
const input = e.target as HTMLInputElement;
|
|
252
|
+
if (!input.files || input.files.length === 0) return;
|
|
253
|
+
const files = Array.from(input.files);
|
|
254
|
+
|
|
255
|
+
importItems = files.map((f) => ({
|
|
256
|
+
filename: f.name,
|
|
257
|
+
status: "uploading",
|
|
258
|
+
}));
|
|
259
|
+
importOpen = true;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const results = await importDocuments(files, activeFolderId || undefined);
|
|
263
|
+
|
|
264
|
+
importItems = importItems.map((item, idx) => {
|
|
265
|
+
const res = results.items[idx];
|
|
266
|
+
if (!res) return { ...item, status: "error", error: "No response" };
|
|
267
|
+
if (res.status === "ok") {
|
|
268
|
+
return {
|
|
269
|
+
...item,
|
|
270
|
+
status: "done",
|
|
271
|
+
documentId: res.document?.id,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
...item,
|
|
276
|
+
status: "error",
|
|
277
|
+
error: res.error || "Failed",
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// The page data is immutable until SvelteKit invalidates the route. Keep
|
|
282
|
+
// successful imports in the dashboard's local projection immediately so
|
|
283
|
+
// Recent Documents updates without requiring a full reload.
|
|
284
|
+
const importedDocuments: Document[] = results.items
|
|
285
|
+
.flatMap((item) =>
|
|
286
|
+
item.status === "ok" && item.document ? [item.document] : [],
|
|
287
|
+
)
|
|
288
|
+
.map((doc) => {
|
|
289
|
+
return {
|
|
290
|
+
id: doc.id,
|
|
291
|
+
title: doc.title,
|
|
292
|
+
content: doc.content,
|
|
293
|
+
folderId: doc.folderId ?? null,
|
|
294
|
+
folderName: doc.folderName ?? "",
|
|
295
|
+
categoryId: doc.categoryId ?? null,
|
|
296
|
+
tags: (doc.tags ?? []).map((tag) => tag.id),
|
|
297
|
+
createdAt: doc.createdAt,
|
|
298
|
+
updatedAt: doc.updatedAt,
|
|
299
|
+
excerpt: doc.excerpt ?? doc.content?.slice(0, 200) ?? "",
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
if (importedDocuments.length > 0) {
|
|
303
|
+
optimisticDocuments = [
|
|
304
|
+
...optimisticDocuments.filter(
|
|
305
|
+
(existing) =>
|
|
306
|
+
!importedDocuments.some((doc) => doc.id === existing.id),
|
|
307
|
+
),
|
|
308
|
+
...importedDocuments,
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await invalidateAll();
|
|
313
|
+
refreshDocs();
|
|
314
|
+
} catch (err) {
|
|
315
|
+
console.error("Import failed:", err);
|
|
316
|
+
importItems = importItems.map((item) => ({
|
|
317
|
+
...item,
|
|
318
|
+
status: "error",
|
|
319
|
+
error: err instanceof Error ? err.message : m.error_generic(),
|
|
320
|
+
}));
|
|
321
|
+
} finally {
|
|
322
|
+
input.value = "";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Helper since we import importDocuments locally in load but import is in documents API
|
|
327
|
+
import { importDocuments } from "$lib/api/documents";
|
|
328
|
+
|
|
329
|
+
function closeImport() {
|
|
330
|
+
importOpen = false;
|
|
331
|
+
setTimeout(() => {
|
|
332
|
+
importItems = [];
|
|
333
|
+
}, 200);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- Reset/Clear Filters ---
|
|
337
|
+
function clearFilters() {
|
|
338
|
+
searchQuery = "";
|
|
339
|
+
selectedTagId = null;
|
|
340
|
+
dateFrom = "";
|
|
341
|
+
dateTo = "";
|
|
342
|
+
|
|
343
|
+
const params = new URLSearchParams(page.url.searchParams);
|
|
344
|
+
params.delete("q");
|
|
345
|
+
params.delete("tag");
|
|
346
|
+
params.delete("dateFrom");
|
|
347
|
+
params.delete("dateTo");
|
|
348
|
+
goto(`/?${params.toString()}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function updateFilters() {
|
|
352
|
+
const params = new URLSearchParams(page.url.searchParams);
|
|
353
|
+
if (searchQuery.trim()) params.set("q", searchQuery);
|
|
354
|
+
else params.delete("q");
|
|
355
|
+
|
|
356
|
+
if (selectedTagId) params.set("tag", selectedTagId);
|
|
357
|
+
else params.delete("tag");
|
|
358
|
+
|
|
359
|
+
if (dateFrom) params.set("dateFrom", dateFrom);
|
|
360
|
+
else params.delete("dateFrom");
|
|
361
|
+
|
|
362
|
+
if (dateTo) params.set("dateTo", dateTo);
|
|
363
|
+
else params.delete("dateTo");
|
|
364
|
+
|
|
365
|
+
goto(`/?${params.toString()}`, {
|
|
366
|
+
replaceState: true,
|
|
367
|
+
keepFocus: true,
|
|
368
|
+
noScroll: true,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const hasActiveFilters = $derived(
|
|
373
|
+
searchQuery.trim() !== "" ||
|
|
374
|
+
selectedTagId !== null ||
|
|
375
|
+
dateFrom !== "" ||
|
|
376
|
+
dateTo !== "",
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// --- Filtering Logic (Client Side) ---
|
|
380
|
+
const filteredFolders = $derived.by(() => {
|
|
381
|
+
let list = activeFolderId
|
|
382
|
+
? (data.activeFolder?.children ?? [])
|
|
383
|
+
: data.rootFolders;
|
|
384
|
+
|
|
385
|
+
// Filter by active category if at root workspace
|
|
386
|
+
if (!activeFolderId && activeCategoryId) {
|
|
387
|
+
list = list.filter((f: FolderType) => f.categoryId === activeCategoryId);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Filter by search query locally
|
|
391
|
+
if (searchQuery.trim()) {
|
|
392
|
+
const q = searchQuery.toLowerCase();
|
|
393
|
+
list = list.filter((f: FolderType) => f.name.toLowerCase().includes(q));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Filter by date range (updatedAt)
|
|
397
|
+
if (dateFrom) {
|
|
398
|
+
const fromTime = new Date(dateFrom).getTime();
|
|
399
|
+
list = list.filter(
|
|
400
|
+
(f: FolderType) => new Date(f.updatedAt).getTime() >= fromTime,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (dateTo) {
|
|
404
|
+
const toTime = new Date(dateTo).getTime();
|
|
405
|
+
list = list.filter(
|
|
406
|
+
(f: FolderType) => new Date(f.updatedAt).getTime() <= toTime,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return list;
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const filteredDocuments = $derived.by(() => {
|
|
414
|
+
let list = activeFolderId
|
|
415
|
+
? (data.activeFolder?.documents ?? [])
|
|
416
|
+
: data.recentDocs;
|
|
417
|
+
const extras = optimisticDocuments.filter(
|
|
418
|
+
(doc) => !list.some((current: Document) => current.id === doc.id),
|
|
419
|
+
);
|
|
420
|
+
if (extras.length > 0) list = [...list, ...extras];
|
|
421
|
+
// Keep the newest document first after optimistic imports/duplicates too.
|
|
422
|
+
// The API already returns this order, but local extras are otherwise
|
|
423
|
+
// appended and end up at the bottom of the single-column mobile grid.
|
|
424
|
+
list = [...list].sort(
|
|
425
|
+
(a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Filter by search query locally
|
|
429
|
+
if (searchQuery.trim()) {
|
|
430
|
+
const q = searchQuery.toLowerCase();
|
|
431
|
+
list = list.filter(
|
|
432
|
+
(d: Document) =>
|
|
433
|
+
d.title.toLowerCase().includes(q) ||
|
|
434
|
+
d.content?.toLowerCase().includes(q),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Filter by tag
|
|
439
|
+
if (selectedTagId) {
|
|
440
|
+
const tagId = selectedTagId;
|
|
441
|
+
list = list.filter((d: Document) => d.tags.includes(tagId));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Filter by date range (updatedAt)
|
|
445
|
+
if (dateFrom) {
|
|
446
|
+
const fromTime = new Date(dateFrom).getTime();
|
|
447
|
+
list = list.filter(
|
|
448
|
+
(d: Document) => new Date(d.updatedAt).getTime() >= fromTime,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (dateTo) {
|
|
452
|
+
const toTime = new Date(dateTo).getTime();
|
|
453
|
+
list = list.filter(
|
|
454
|
+
(d: Document) => new Date(d.updatedAt).getTime() <= toTime,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return list;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Build grouped sections for folders (grouped by category)
|
|
462
|
+
const visibleSections = $derived.by(() => {
|
|
463
|
+
const byCategory = new Map<string, FolderType[]>();
|
|
464
|
+
for (const cat of data.categories) byCategory.set(cat.id, []);
|
|
465
|
+
const uncategorized: FolderType[] = [];
|
|
466
|
+
|
|
467
|
+
for (const folder of filteredFolders) {
|
|
468
|
+
if (folder.categoryId && byCategory.has(folder.categoryId)) {
|
|
469
|
+
byCategory.get(folder.categoryId)?.push(folder);
|
|
470
|
+
} else {
|
|
471
|
+
uncategorized.push(folder);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const items: Array<{
|
|
476
|
+
key: string;
|
|
477
|
+
category: Category | null;
|
|
478
|
+
folders: FolderType[];
|
|
479
|
+
}> = [];
|
|
480
|
+
for (const cat of data.categories) {
|
|
481
|
+
items.push({
|
|
482
|
+
key: cat.id,
|
|
483
|
+
category: cat,
|
|
484
|
+
folders: byCategory.get(cat.id) ?? [],
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
items.push({
|
|
488
|
+
key: "__uncategorized__",
|
|
489
|
+
category: null,
|
|
490
|
+
folders: uncategorized,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// If filtering by a single category, only show that category section!
|
|
494
|
+
if (activeCategoryId) {
|
|
495
|
+
return items.filter((s) => s.key === activeCategoryId);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return items.filter((s) => s.folders.length > 0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const isRootEmpty = $derived(
|
|
502
|
+
filteredFolders.length === 0 && filteredDocuments.length === 0,
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const isFolderEmpty = $derived(
|
|
506
|
+
activeFolderId &&
|
|
507
|
+
(data.activeFolder?.children?.length ?? 0) === 0 &&
|
|
508
|
+
(data.activeFolder?.documents?.length ?? 0) === 0,
|
|
509
|
+
);
|
|
510
|
+
</script>
|
|
511
|
+
|
|
512
|
+
<svelte:head>
|
|
513
|
+
<title>
|
|
514
|
+
{activeFolderId
|
|
515
|
+
? m.folder_page_title({ name: data.activeFolder?.name || "Folder" })
|
|
516
|
+
: activeCategoryId
|
|
517
|
+
? (data.categories.find((c: Category) => c.id === activeCategoryId)?.name || "Category")
|
|
518
|
+
: m.dashboard_page_title()}
|
|
519
|
+
</title>
|
|
520
|
+
</svelte:head>
|
|
521
|
+
|
|
522
|
+
<div class="dashboard-shell mx-auto max-w-5xl px-6 py-8">
|
|
523
|
+
<!-- Header -->
|
|
524
|
+
<div class="dashboard-header mb-8 flex flex-wrap items-center justify-between gap-4">
|
|
525
|
+
<div class="dashboard-context-identity flex items-center gap-3 flex-1 min-w-0">
|
|
526
|
+
{#if activeFolderId}
|
|
527
|
+
<Button
|
|
528
|
+
variant="ghost"
|
|
529
|
+
size="icon"
|
|
530
|
+
class="size-8 shrink-0"
|
|
531
|
+
onclick={() => {
|
|
532
|
+
const parentId = data.activeFolder?.parentId;
|
|
533
|
+
if (parentId) {
|
|
534
|
+
goto(`/?folder=${parentId}`);
|
|
535
|
+
} else {
|
|
536
|
+
goto("/");
|
|
537
|
+
}
|
|
538
|
+
}}
|
|
539
|
+
title={m.action_back()}
|
|
540
|
+
>
|
|
541
|
+
<ArrowLeft class="size-4" />
|
|
542
|
+
</Button>
|
|
543
|
+
<FolderOpen class="size-7 shrink-0 text-primary" />
|
|
544
|
+
<h1 class="text-2xl font-semibold tracking-tight truncate">
|
|
545
|
+
{data.activeFolder?.name || "Folder"}
|
|
546
|
+
</h1>
|
|
547
|
+
{@const activeFolderCategoryId = data.activeFolder?.categoryId}
|
|
548
|
+
{#if activeFolderCategoryId}
|
|
549
|
+
{@const cat = data.categories.find((c: Category) => c.id === activeFolderCategoryId)}
|
|
550
|
+
{#if cat}
|
|
551
|
+
<Badge variant="secondary">{cat.name}</Badge>
|
|
552
|
+
{/if}
|
|
553
|
+
{/if}
|
|
554
|
+
{:else if activeCategoryId}
|
|
555
|
+
<Button
|
|
556
|
+
variant="ghost"
|
|
557
|
+
size="icon"
|
|
558
|
+
class="size-8 shrink-0"
|
|
559
|
+
onclick={() => goto("/")}
|
|
560
|
+
title={m.action_back()}
|
|
561
|
+
>
|
|
562
|
+
<ArrowLeft class="size-4" />
|
|
563
|
+
</Button>
|
|
564
|
+
<FolderKanban class="size-7 shrink-0 text-primary" />
|
|
565
|
+
<h1 class="text-2xl font-semibold tracking-tight truncate">
|
|
566
|
+
{data.categories.find((c: Category) => c.id === activeCategoryId)?.name || "Category"}
|
|
567
|
+
</h1>
|
|
568
|
+
{:else}
|
|
569
|
+
<div class="dashboard-identity flex items-center gap-3">
|
|
570
|
+
<div class="flex size-10 items-center justify-center rounded-lg bg-primary/10">
|
|
571
|
+
<LayoutDashboard class="size-5 text-primary" />
|
|
572
|
+
</div>
|
|
573
|
+
<div>
|
|
574
|
+
<h1 class="text-2xl font-semibold tracking-tight">{m.dashboard_title()}</h1>
|
|
575
|
+
<p class="text-sm text-muted-foreground">{m.dashboard_subtitle()}</p>
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
{/if}
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
<!-- Top Action Buttons -->
|
|
582
|
+
<div class="dashboard-header-actions ml-auto flex items-center justify-end gap-2">
|
|
583
|
+
<input
|
|
584
|
+
type="file"
|
|
585
|
+
accept=".md,.txt,.json,.markdown,.docx"
|
|
586
|
+
multiple
|
|
587
|
+
class="hidden"
|
|
588
|
+
bind:this={importInput}
|
|
589
|
+
onchange={handleImportFile}
|
|
590
|
+
/>
|
|
591
|
+
<Button variant="outline" size="sm" onclick={triggerImport} class="text-muted-foreground" aria-label={m.dashboard_import()}>
|
|
592
|
+
<Upload class="size-4" />
|
|
593
|
+
<span class="dashboard-action-label">{m.dashboard_import()}</span>
|
|
594
|
+
</Button>
|
|
595
|
+
<Button size="sm" onclick={handleNewDocument} aria-label={m.dashboard_new_document()}>
|
|
596
|
+
<Plus class="size-4" />
|
|
597
|
+
<span class="dashboard-action-label">{m.dashboard_new_document()}</span>
|
|
598
|
+
</Button>
|
|
599
|
+
{#if activeFolderId && data.activeFolder}
|
|
600
|
+
<Button variant="outline" size="sm" onclick={() => openShareDialogForFolder(activeFolderId, data.activeFolder?.name || "Folder")} aria-label={m.doc_share()}>
|
|
601
|
+
<Share2 class="size-3.5" />
|
|
602
|
+
<span class="dashboard-action-label">{m.doc_share()}</span>
|
|
603
|
+
</Button>
|
|
604
|
+
{:else if activeCategoryId}
|
|
605
|
+
{@const activeCategory = data.categories.find((category: Category) => category.id === activeCategoryId)}
|
|
606
|
+
{#if activeCategory}
|
|
607
|
+
<Button variant="outline" size="sm" onclick={() => openShareDialogForCategory(activeCategory.id, activeCategory.name)} aria-label={m.doc_share()}>
|
|
608
|
+
<Share2 class="size-3.5" />
|
|
609
|
+
<span class="dashboard-action-label">{m.doc_share()}</span>
|
|
610
|
+
</Button>
|
|
611
|
+
{/if}
|
|
612
|
+
{/if}
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
{#if dashboardWidgets.length > 0}
|
|
617
|
+
<section
|
|
618
|
+
class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2"
|
|
619
|
+
data-hiai-docs-extension-zone="dashboard-widgets"
|
|
620
|
+
aria-label="Dashboard extensions"
|
|
621
|
+
>
|
|
622
|
+
{#each dashboardWidgets as widget (widget.id)}
|
|
623
|
+
{@const Widget = widget.component}
|
|
624
|
+
<div
|
|
625
|
+
class="rounded-xl border border-border bg-card p-4"
|
|
626
|
+
data-hiai-docs-extension-id={widget.id}
|
|
627
|
+
>
|
|
628
|
+
{#if widget.title}
|
|
629
|
+
<h2 class="mb-3 text-sm font-semibold">{widget.title}</h2>
|
|
630
|
+
{/if}
|
|
631
|
+
<Widget userId={extensionContext.userId} />
|
|
632
|
+
</div>
|
|
633
|
+
{/each}
|
|
634
|
+
</section>
|
|
635
|
+
{/if}
|
|
636
|
+
|
|
637
|
+
<!-- Breadcrumbs (for folder detail view) -->
|
|
638
|
+
{#if activeFolderId && data.breadcrumb?.length > 0}
|
|
639
|
+
<nav class="mb-6 flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground" aria-label="Breadcrumbs">
|
|
640
|
+
<a href="/" class="hover:text-foreground transition-colors">Home</a>
|
|
641
|
+
{#each data.breadcrumb as path, idx (path.id)}
|
|
642
|
+
<ChevronRight class="size-3.5" />
|
|
643
|
+
{#if idx === data.breadcrumb.length - 1}
|
|
644
|
+
<span class="font-medium text-foreground truncate max-w-[150px]">{path.name}</span>
|
|
645
|
+
{:else}
|
|
646
|
+
<a href="/?folder={path.id}" class="hover:text-foreground transition-colors truncate max-w-[150px]">
|
|
647
|
+
{path.name}
|
|
648
|
+
</a>
|
|
649
|
+
{/if}
|
|
650
|
+
{/each}
|
|
651
|
+
</nav>
|
|
652
|
+
{/if}
|
|
653
|
+
|
|
654
|
+
<!-- Search & Filters Grid Zone -->
|
|
655
|
+
<div class="mb-8 grid grid-cols-1 gap-4 rounded-xl border border-border bg-card p-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
656
|
+
<!-- Local Search Input -->
|
|
657
|
+
<div class="space-y-1.5">
|
|
658
|
+
<Label for="dash-search" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Search</Label>
|
|
659
|
+
<input
|
|
660
|
+
id="dash-search"
|
|
661
|
+
type="text"
|
|
662
|
+
bind:value={searchQuery}
|
|
663
|
+
oninput={updateFilters}
|
|
664
|
+
placeholder="Filter current view..."
|
|
665
|
+
class="h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
666
|
+
/>
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
<!-- Category selector (only shown at root workspace) -->
|
|
670
|
+
<div class="space-y-1.5">
|
|
671
|
+
<Label for="dash-category" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Category</Label>
|
|
672
|
+
{#if activeFolderId}
|
|
673
|
+
<div class="h-9 w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm shadow-sm opacity-60 flex items-center select-none">
|
|
674
|
+
Inherited from folder
|
|
675
|
+
</div>
|
|
676
|
+
{:else}
|
|
677
|
+
<Select.Root
|
|
678
|
+
type="single"
|
|
679
|
+
value={activeCategoryId ?? "all"}
|
|
680
|
+
onValueChange={(val: string) => {
|
|
681
|
+
const params = new URLSearchParams(page.url.searchParams);
|
|
682
|
+
if (val && val !== "all") params.set("category", val);
|
|
683
|
+
else params.delete("category");
|
|
684
|
+
goto(`/?${params.toString()}`);
|
|
685
|
+
}}
|
|
686
|
+
>
|
|
687
|
+
<Select.Trigger class="w-full text-foreground flex items-center justify-between bg-background border border-input px-3 py-2 text-sm rounded-md shadow-sm h-9">
|
|
688
|
+
<Select.Value placeholder="All Categories">
|
|
689
|
+
{activeCategoryId ? (data.categories.find((c: Category) => c.id === activeCategoryId)?.name ?? "All Categories") : "All Categories"}
|
|
690
|
+
</Select.Value>
|
|
691
|
+
<ChevronDown class="size-4 opacity-50" />
|
|
692
|
+
</Select.Trigger>
|
|
693
|
+
<Select.Content class="w-[var(--bits-select-trigger-width)]">
|
|
694
|
+
<Select.Item value="all">All Categories</Select.Item>
|
|
695
|
+
{#each data.categories as cat (cat.id)}
|
|
696
|
+
<Select.Item value={cat.id}>{cat.name}</Select.Item>
|
|
697
|
+
{/each}
|
|
698
|
+
</Select.Content>
|
|
699
|
+
</Select.Root>
|
|
700
|
+
{/if}
|
|
701
|
+
</div>
|
|
702
|
+
|
|
703
|
+
<!-- Tag Dropdown Selection -->
|
|
704
|
+
<div class="space-y-1.5">
|
|
705
|
+
<Label for="dash-tag" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Tag</Label>
|
|
706
|
+
<Select.Root
|
|
707
|
+
type="single"
|
|
708
|
+
value={selectedTagId ?? "all"}
|
|
709
|
+
onValueChange={(val: string) => {
|
|
710
|
+
selectedTagId = val === "all" ? null : val;
|
|
711
|
+
updateFilters();
|
|
712
|
+
}}
|
|
713
|
+
>
|
|
714
|
+
<Select.Trigger class="w-full text-foreground flex items-center justify-between bg-background border border-input px-3 py-2 text-sm rounded-md shadow-sm h-9">
|
|
715
|
+
<Select.Value placeholder="All Tags">
|
|
716
|
+
{selectedTagId ? (data.tags.find((t: ApiTag) => t.id === selectedTagId)?.name ?? "All Tags") : "All Tags"}
|
|
717
|
+
</Select.Value>
|
|
718
|
+
<ChevronDown class="size-4 opacity-50" />
|
|
719
|
+
</Select.Trigger>
|
|
720
|
+
<Select.Content class="w-[var(--bits-select-trigger-width)]">
|
|
721
|
+
<Select.Item value="all">All Tags</Select.Item>
|
|
722
|
+
{#each data.tags as tag (tag.id)}
|
|
723
|
+
<Select.Item value={tag.id}>{tag.name}</Select.Item>
|
|
724
|
+
{/each}
|
|
725
|
+
</Select.Content>
|
|
726
|
+
</Select.Root>
|
|
727
|
+
</div>
|
|
728
|
+
|
|
729
|
+
<!-- Date From -->
|
|
730
|
+
<div class="space-y-1.5">
|
|
731
|
+
<Label for="date-from" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">From Date</Label>
|
|
732
|
+
<DatePicker
|
|
733
|
+
id="date-from"
|
|
734
|
+
bind:value={dateFrom}
|
|
735
|
+
onchange={updateFilters}
|
|
736
|
+
placeholder="From Date"
|
|
737
|
+
/>
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
<!-- Date To -->
|
|
741
|
+
<div class="space-y-1.5">
|
|
742
|
+
<Label for="date-to" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">To Date</Label>
|
|
743
|
+
<DatePicker
|
|
744
|
+
id="date-to"
|
|
745
|
+
bind:value={dateTo}
|
|
746
|
+
onchange={updateFilters}
|
|
747
|
+
placeholder="To Date"
|
|
748
|
+
/>
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
|
|
752
|
+
<!-- Active Filters Summary & Clear Filters -->
|
|
753
|
+
{#if hasActiveFilters}
|
|
754
|
+
<div class="mb-6 flex flex-wrap items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-2 text-sm">
|
|
755
|
+
<span class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Filters:</span>
|
|
756
|
+
{#if searchQuery.trim()}
|
|
757
|
+
<Badge variant="secondary" class="flex items-center gap-1">
|
|
758
|
+
<span>Search: {searchQuery}</span>
|
|
759
|
+
<button onclick={() => { searchQuery = ""; updateFilters(); }} class="text-muted-foreground hover:text-foreground">
|
|
760
|
+
<X class="size-3" />
|
|
761
|
+
</button>
|
|
762
|
+
</Badge>
|
|
763
|
+
{/if}
|
|
764
|
+
{#if selectedTagId}
|
|
765
|
+
{@const tag = data.tags.find((t: ApiTag) => t.id === selectedTagId)}
|
|
766
|
+
{#if tag}
|
|
767
|
+
<Badge variant="secondary" class="flex items-center gap-1.5">
|
|
768
|
+
<span class="size-2 rounded-full" style="background-color: {tag.color || '#cccccc'}"></span>
|
|
769
|
+
<span>Tag: {tag.name}</span>
|
|
770
|
+
<button onclick={() => { selectedTagId = null; updateFilters(); }} class="text-muted-foreground hover:text-foreground">
|
|
771
|
+
<X class="size-3" />
|
|
772
|
+
</button>
|
|
773
|
+
</Badge>
|
|
774
|
+
{/if}
|
|
775
|
+
{/if}
|
|
776
|
+
{#if dateFrom || dateTo}
|
|
777
|
+
<Badge variant="secondary" class="flex items-center gap-1">
|
|
778
|
+
<span>Date: {dateFrom || "*"} to {dateTo || "*"}</span>
|
|
779
|
+
<button onclick={() => { dateFrom = ""; dateTo = ""; updateFilters(); }} class="text-muted-foreground hover:text-foreground">
|
|
780
|
+
<X class="size-3" />
|
|
781
|
+
</button>
|
|
782
|
+
</Badge>
|
|
783
|
+
{/if}
|
|
784
|
+
<button
|
|
785
|
+
onclick={clearFilters}
|
|
786
|
+
class="ml-auto inline-flex items-center gap-1 text-xs font-semibold text-destructive hover:underline"
|
|
787
|
+
>
|
|
788
|
+
<RotateCcw class="size-3" />
|
|
789
|
+
Clear Filters
|
|
790
|
+
</button>
|
|
791
|
+
</div>
|
|
792
|
+
{/if}
|
|
793
|
+
|
|
794
|
+
<!-- MAIN CONTENT AREA -->
|
|
795
|
+
{#if activeFolderId}
|
|
796
|
+
<!-- ================= FOLDER VIEW ================= -->
|
|
797
|
+
{#if isFolderEmpty}
|
|
798
|
+
<!-- Empty folder state -->
|
|
799
|
+
<div class="flex flex-col items-center justify-center py-20 text-center">
|
|
800
|
+
<div class="mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
|
|
801
|
+
<FolderOpen class="size-8 text-muted-foreground" />
|
|
802
|
+
</div>
|
|
803
|
+
<h2 class="mb-2 text-lg font-semibold">Folder is empty</h2>
|
|
804
|
+
<p class="mb-6 max-w-sm text-sm text-muted-foreground">
|
|
805
|
+
Create a new document, subfolder, or drag and drop items here to get started.
|
|
806
|
+
</p>
|
|
807
|
+
</div>
|
|
808
|
+
{:else}
|
|
809
|
+
<!-- Subfolders List -->
|
|
810
|
+
{#if filteredFolders.length > 0}
|
|
811
|
+
<div class="mb-8">
|
|
812
|
+
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
813
|
+
Subfolders
|
|
814
|
+
</h2>
|
|
815
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
816
|
+
{#each filteredFolders as folder (folder.id)}
|
|
817
|
+
<FolderCard
|
|
818
|
+
{folder}
|
|
819
|
+
onDelete={handleDeleteFolder}
|
|
820
|
+
onRename={handleRenameFolder}
|
|
821
|
+
onShare={openShareDialogForFolder}
|
|
822
|
+
/>
|
|
823
|
+
{/each}
|
|
824
|
+
</div>
|
|
825
|
+
</div>
|
|
826
|
+
{/if}
|
|
827
|
+
|
|
828
|
+
<!-- Documents List -->
|
|
829
|
+
{#if filteredDocuments.length > 0}
|
|
830
|
+
<div>
|
|
831
|
+
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
832
|
+
{m.nav_documents()}
|
|
833
|
+
</h2>
|
|
834
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
835
|
+
{#each filteredDocuments as doc (doc.id)}
|
|
836
|
+
<DocumentCard
|
|
837
|
+
document={doc}
|
|
838
|
+
onDelete={handleDeleteDocument}
|
|
839
|
+
onDuplicate={handleDuplicateDocument}
|
|
840
|
+
onShare={openShareDialogForDocument}
|
|
841
|
+
duplicateBusy={duplicatingDocumentId === doc.id}
|
|
842
|
+
/>
|
|
843
|
+
{/each}
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
{/if}
|
|
847
|
+
{/if}
|
|
848
|
+
|
|
849
|
+
{:else}
|
|
850
|
+
<!-- ================= DASHBOARD / ROOT VIEW ================= -->
|
|
851
|
+
{#if isRootEmpty}
|
|
852
|
+
<!-- Empty Workspace State -->
|
|
853
|
+
<div class="flex flex-col items-center justify-center py-20 text-center">
|
|
854
|
+
<div class="mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
|
|
855
|
+
<FileText class="size-8 text-muted-foreground" />
|
|
856
|
+
</div>
|
|
857
|
+
<h2 class="mb-2 text-lg font-semibold">{m.folders_empty()}</h2>
|
|
858
|
+
<p class="mb-6 max-w-sm text-sm text-muted-foreground">
|
|
859
|
+
{m.folders_empty_description()}
|
|
860
|
+
</p>
|
|
861
|
+
</div>
|
|
862
|
+
{:else}
|
|
863
|
+
<!-- Grouped Category Sections of Folders -->
|
|
864
|
+
{#each visibleSections as section (section.key)}
|
|
865
|
+
{@const docSum = section.folders.reduce((acc, f) => acc + f.documentCount, 0)}
|
|
866
|
+
<section id="category-{section.key}" class="mb-8">
|
|
867
|
+
<div class="mb-3 flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
868
|
+
<h2>{section.category ? section.category.name : m.sidebar_uncategorized()}</h2>
|
|
869
|
+
<Badge variant="secondary" class="text-[10px]">
|
|
870
|
+
{docSum} {docSum === 1 ? 'file' : 'files'}
|
|
871
|
+
</Badge>
|
|
872
|
+
{#if section.category}
|
|
873
|
+
<DropdownMenu>
|
|
874
|
+
<DropdownMenuTrigger class="ml-auto inline-flex size-8 items-center justify-center rounded-md hover:bg-accent" aria-label={m.editor_more_options()}>
|
|
875
|
+
<MoreVertical class="size-4" />
|
|
876
|
+
</DropdownMenuTrigger>
|
|
877
|
+
<DropdownMenuContent align="end">
|
|
878
|
+
<DropdownMenuItem onSelect={() => openShareDialogForCategory(section.category!.id, section.category!.name)}>
|
|
879
|
+
<Share2 class="size-4" />
|
|
880
|
+
{m.doc_share()}
|
|
881
|
+
</DropdownMenuItem>
|
|
882
|
+
</DropdownMenuContent>
|
|
883
|
+
</DropdownMenu>
|
|
884
|
+
{/if}
|
|
885
|
+
</div>
|
|
886
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
887
|
+
{#each section.folders as folder (folder.id)}
|
|
888
|
+
<FolderCard
|
|
889
|
+
{folder}
|
|
890
|
+
onDelete={handleDeleteFolder}
|
|
891
|
+
onRename={handleRenameFolder}
|
|
892
|
+
onShare={openShareDialogForFolder}
|
|
893
|
+
/>
|
|
894
|
+
{/each}
|
|
895
|
+
</div>
|
|
896
|
+
</section>
|
|
897
|
+
{/each}
|
|
898
|
+
|
|
899
|
+
<!-- Recent Documents Section (shown at the bottom) -->
|
|
900
|
+
{#if filteredDocuments.length > 0}
|
|
901
|
+
<div class="mt-12 border-t border-border/60 pt-8">
|
|
902
|
+
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
903
|
+
{hasActiveFilters ? "Filtered Documents" : "Recent Documents"}
|
|
904
|
+
</h2>
|
|
905
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
906
|
+
{#each filteredDocuments as doc (doc.id)}
|
|
907
|
+
<DocumentCard
|
|
908
|
+
document={doc}
|
|
909
|
+
onDelete={handleDeleteDocument}
|
|
910
|
+
onDuplicate={handleDuplicateDocument}
|
|
911
|
+
onShare={openShareDialogForDocument}
|
|
912
|
+
duplicateBusy={duplicatingDocumentId === doc.id}
|
|
913
|
+
/>
|
|
914
|
+
{/each}
|
|
915
|
+
</div>
|
|
916
|
+
</div>
|
|
917
|
+
{/if}
|
|
918
|
+
{/if}
|
|
919
|
+
{/if}
|
|
920
|
+
</div>
|
|
921
|
+
|
|
922
|
+
<style>
|
|
923
|
+
.dashboard-shell {
|
|
924
|
+
position: relative;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
@media (max-width: 767px) {
|
|
928
|
+
.dashboard-header {
|
|
929
|
+
min-height: 44px;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
.dashboard-header-actions {
|
|
933
|
+
position: absolute;
|
|
934
|
+
top: calc(0.75rem + env(safe-area-inset-top));
|
|
935
|
+
right: 0.75rem;
|
|
936
|
+
z-index: 10;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.dashboard-context-identity {
|
|
940
|
+
margin-left: 56px;
|
|
941
|
+
min-width: 0;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
@media (max-width: 520px) {
|
|
946
|
+
.dashboard-header-actions :global(button) {
|
|
947
|
+
width: 40px;
|
|
948
|
+
height: 40px;
|
|
949
|
+
padding: 0;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.dashboard-action-label {
|
|
953
|
+
display: none;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
@media (max-width: 449px) {
|
|
958
|
+
.dashboard-context-identity {
|
|
959
|
+
margin-left: 52px;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
.dashboard-context-identity p {
|
|
963
|
+
display: none;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
.dashboard-context-identity h1 {
|
|
967
|
+
font-size: 1.25rem;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
</style>
|
|
971
|
+
|
|
972
|
+
<!-- Folder creation / renaming dialog -->
|
|
973
|
+
<FolderDialog
|
|
974
|
+
bind:open={showFolderDialog}
|
|
975
|
+
mode={folderDialogMode}
|
|
976
|
+
folder={folderDialogTarget}
|
|
977
|
+
onSave={saveFolder}
|
|
978
|
+
/>
|
|
979
|
+
|
|
980
|
+
<!-- Delete folder confirmation dialog -->
|
|
981
|
+
<ConfirmDialog
|
|
982
|
+
bind:open={showDeleteFolderDialog}
|
|
983
|
+
title={m.folders_delete_title()}
|
|
984
|
+
description={m.folders_delete_description()}
|
|
985
|
+
confirmLabel={m.folders_delete()}
|
|
986
|
+
cancelLabel={m.action_cancel()}
|
|
987
|
+
variant="destructive"
|
|
988
|
+
busy={deleteFolderBusy}
|
|
989
|
+
onConfirm={confirmDeleteFolder}
|
|
990
|
+
onCancel={() => (showDeleteFolderDialog = false)}
|
|
991
|
+
/>
|
|
992
|
+
|
|
993
|
+
<!-- Shared dialog for document, folder, and category entry points. -->
|
|
994
|
+
{#if shareTarget}
|
|
995
|
+
<ShareDialog
|
|
996
|
+
bind:open={showShareDialog}
|
|
997
|
+
documentId={shareTarget.kind === "document" ? shareTarget.documentId : ""}
|
|
998
|
+
documentTitle={shareTarget.kind === "document" ? shareTarget.title : ""}
|
|
999
|
+
folderId={shareTarget.kind === "folder" ? shareTarget.folderId : ""}
|
|
1000
|
+
folderName={shareTarget.kind === "folder" ? shareTarget.name : ""}
|
|
1001
|
+
categoryId={shareTarget.kind === "category" ? shareTarget.categoryId : ""}
|
|
1002
|
+
categoryName={shareTarget.kind === "category" ? shareTarget.name : ""}
|
|
1003
|
+
/>
|
|
1004
|
+
{/if}
|
|
1005
|
+
|
|
1006
|
+
<!-- Multi-file import progress dialog overlay -->
|
|
1007
|
+
<ImportProgress open={importOpen} items={importItems} onClose={closeImport} />
|