- FileBrowser: modernize breadcrumbs, toolbar, list/grid items, empty states - KanbanColumn: remove fixed height, border-based styling, compact header - KanbanCard: cleaner border styling, smaller tags, compact footer - Calendar: compact nav bar, border grid, today circle indicator, day view empty state - DocumentViewer: remove bg-night rounded-[32px], border-b header pattern - Settings tags: inline border/rounded-xl cards, icon action buttons - Chat: create +layout.svelte with PageHeader, overhaul sidebar and main area - Chat i18n: add nav_chat, chat_title, chat_subtitle keys (en + et) svelte-check: 0 errors, vitest: 112/112 passed
892 lines
25 KiB
Svelte
892 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { getContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import {
|
|
Button,
|
|
Modal,
|
|
Input,
|
|
} from "$lib/components/ui";
|
|
import { DocumentViewer } from "$lib/components/documents";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
import { toasts } from "$lib/stores/toast.svelte";
|
|
import type { Document } from "$lib/supabase/types";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { Database } from "$lib/supabase/types";
|
|
import * as m from "$lib/paraglide/messages";
|
|
import { logActivity } from "$lib/api/activity";
|
|
import {
|
|
moveDocument,
|
|
updateDocument,
|
|
deleteDocument,
|
|
createDocument,
|
|
copyDocument,
|
|
} from "$lib/api/documents";
|
|
|
|
const log = createLogger("component.file-browser");
|
|
|
|
interface Props {
|
|
org: { id: string; name: string; slug: string };
|
|
documents: Document[];
|
|
currentFolderId: string | null;
|
|
user: { id: string } | null;
|
|
/** Page title shown in the header */
|
|
title?: string;
|
|
}
|
|
|
|
let {
|
|
org,
|
|
documents = $bindable(),
|
|
currentFolderId,
|
|
user,
|
|
title = m.files_title(),
|
|
}: Props = $props();
|
|
|
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
|
|
|
let selectedDoc = $state<Document | null>(null);
|
|
let showCreateModal = $state(false);
|
|
let showEditModal = $state(false);
|
|
let editingDoc = $state<Document | null>(null);
|
|
let newDocName = $state("");
|
|
let newDocType = $state<"folder" | "document" | "kanban">("document");
|
|
let viewMode = $state<"list" | "grid">(
|
|
typeof localStorage !== "undefined" &&
|
|
localStorage.getItem("root:viewMode") === "list"
|
|
? "list"
|
|
: "grid",
|
|
);
|
|
|
|
function toggleViewMode() {
|
|
viewMode = viewMode === "list" ? "grid" : "list";
|
|
if (typeof localStorage !== "undefined") {
|
|
localStorage.setItem("root:viewMode", viewMode);
|
|
}
|
|
}
|
|
|
|
// Context menu state
|
|
let contextMenu = $state<{ x: number; y: number; doc: Document } | null>(
|
|
null,
|
|
);
|
|
let showOrganizeMenu = $state(false);
|
|
|
|
// Sort: folders first, then documents, then kanbans, alphabetical
|
|
function typeOrder(type: string): number {
|
|
if (type === "folder") return 0;
|
|
if (type === "document") return 1;
|
|
if (type === "kanban") return 2;
|
|
return 3;
|
|
}
|
|
|
|
const currentFolderItems = $derived(
|
|
documents
|
|
.filter((d) =>
|
|
currentFolderId === null
|
|
? d.parent_id === null
|
|
: d.parent_id === currentFolderId,
|
|
)
|
|
.sort((a, b) => {
|
|
const typeA = typeOrder(a.type);
|
|
const typeB = typeOrder(b.type);
|
|
if (typeA !== typeB) return typeA - typeB;
|
|
return a.name.localeCompare(b.name);
|
|
}),
|
|
);
|
|
|
|
// Drag and drop state
|
|
let draggedItem = $state<Document | null>(null);
|
|
let dragOverFolder = $state<string | null>(null);
|
|
let isDragging = $state(false);
|
|
let dragOverBreadcrumb = $state<string | null | undefined>(undefined);
|
|
|
|
// Build breadcrumb path
|
|
const breadcrumbPath = $derived.by(() => {
|
|
const path: { id: string | null; name: string }[] = [
|
|
{ id: null, name: "Home" },
|
|
];
|
|
if (currentFolderId === null) return path;
|
|
|
|
let current = documents.find((d) => d.id === currentFolderId);
|
|
const ancestors: { id: string; name: string }[] = [];
|
|
while (current) {
|
|
ancestors.unshift({ id: current.id, name: current.name });
|
|
current = current.parent_id
|
|
? documents.find((d) => d.id === current!.parent_id)
|
|
: undefined;
|
|
}
|
|
return [...path, ...ancestors];
|
|
});
|
|
|
|
// URL helpers
|
|
function getFolderUrl(folderId: string | null): string {
|
|
if (!folderId) return `/${org.slug}/documents`;
|
|
return `/${org.slug}/documents/folder/${folderId}`;
|
|
}
|
|
|
|
function getFileUrl(doc: Document): string {
|
|
return `/${org.slug}/documents/file/${doc.id}`;
|
|
}
|
|
|
|
function getDocIcon(doc: Document): string {
|
|
if (doc.type === "folder") return "folder";
|
|
if (doc.type === "kanban") return "view_kanban";
|
|
return "description";
|
|
}
|
|
|
|
function handleItemClick(doc: Document) {
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
return;
|
|
}
|
|
if (doc.type === "folder") {
|
|
goto(getFolderUrl(doc.id));
|
|
} else if (doc.type === "kanban") {
|
|
goto(getFileUrl(doc));
|
|
} else {
|
|
selectedDoc = doc;
|
|
}
|
|
}
|
|
|
|
function handleDoubleClick(doc: Document) {
|
|
if (doc.type === "folder") {
|
|
window.open(getFolderUrl(doc.id), "_blank");
|
|
} else {
|
|
window.open(getFileUrl(doc), "_blank");
|
|
}
|
|
}
|
|
|
|
function handleAuxClick(e: MouseEvent, doc: Document) {
|
|
if (e.button === 1) {
|
|
e.preventDefault();
|
|
if (doc.type === "folder") {
|
|
window.open(getFolderUrl(doc.id), "_blank");
|
|
} else {
|
|
window.open(getFileUrl(doc), "_blank");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Context menu handlers
|
|
function handleContextMenu(e: MouseEvent, doc: Document) {
|
|
e.preventDefault();
|
|
contextMenu = { x: e.clientX, y: e.clientY, doc };
|
|
showOrganizeMenu = false;
|
|
}
|
|
|
|
function closeContextMenu() {
|
|
contextMenu = null;
|
|
showOrganizeMenu = false;
|
|
}
|
|
|
|
function contextRename() {
|
|
if (!contextMenu) return;
|
|
editingDoc = contextMenu.doc;
|
|
newDocName = contextMenu.doc.name;
|
|
showEditModal = true;
|
|
closeContextMenu();
|
|
}
|
|
|
|
async function contextCopy() {
|
|
if (!contextMenu || !user) return;
|
|
const doc = contextMenu.doc;
|
|
closeContextMenu();
|
|
try {
|
|
const newDoc = await copyDocument(supabase, doc, org.id, user.id);
|
|
documents = [...documents, newDoc];
|
|
toasts.success(`Copied "${doc.name}"`);
|
|
} catch {
|
|
toasts.error("Failed to copy document");
|
|
}
|
|
}
|
|
|
|
function contextOrganize() {
|
|
showOrganizeMenu = !showOrganizeMenu;
|
|
}
|
|
|
|
async function contextMoveToFolder(folderId: string | null) {
|
|
if (!contextMenu) return;
|
|
const doc = contextMenu.doc;
|
|
closeContextMenu();
|
|
await handleMove(doc.id, folderId);
|
|
toasts.success(
|
|
`Moved "${doc.name}" to ${folderId ? (documents.find((d) => d.id === folderId)?.name ?? "folder") : "Home"}`,
|
|
);
|
|
}
|
|
|
|
function contextDelete() {
|
|
if (!contextMenu) return;
|
|
const doc = contextMenu.doc;
|
|
closeContextMenu();
|
|
handleDelete(doc);
|
|
}
|
|
|
|
const availableFolders = $derived(
|
|
documents.filter(
|
|
(d) => d.type === "folder" && d.id !== contextMenu?.doc.id,
|
|
),
|
|
);
|
|
|
|
function handleAdd() {
|
|
showCreateModal = true;
|
|
}
|
|
|
|
// Drag handlers
|
|
function handleDragStart(e: DragEvent, doc: Document) {
|
|
isDragging = true;
|
|
draggedItem = doc;
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", doc.id);
|
|
}
|
|
}
|
|
|
|
function handleDragEnd() {
|
|
resetDragState();
|
|
}
|
|
|
|
function handleDragOver(e: DragEvent, doc: Document) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
if (draggedItem?.id === doc.id) return;
|
|
if (doc.type === "folder") {
|
|
dragOverFolder = doc.id;
|
|
} else {
|
|
dragOverFolder = null;
|
|
}
|
|
}
|
|
|
|
function handleDragLeave() {
|
|
dragOverFolder = null;
|
|
}
|
|
|
|
async function handleDrop(e: DragEvent, targetDoc: Document) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!draggedItem || draggedItem.id === targetDoc.id) {
|
|
resetDragState();
|
|
return;
|
|
}
|
|
if (targetDoc.type === "folder") {
|
|
const draggedName = draggedItem.name;
|
|
await handleMove(draggedItem.id, targetDoc.id);
|
|
toasts.success(`Moved "${draggedName}" into "${targetDoc.name}"`);
|
|
}
|
|
resetDragState();
|
|
}
|
|
|
|
function handleContainerDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
}
|
|
|
|
async function handleDropOnEmpty(e: DragEvent) {
|
|
e.preventDefault();
|
|
if (!draggedItem) return;
|
|
if (draggedItem.parent_id !== currentFolderId) {
|
|
await handleMove(draggedItem.id, currentFolderId);
|
|
}
|
|
resetDragState();
|
|
}
|
|
|
|
function resetDragState() {
|
|
draggedItem = null;
|
|
dragOverFolder = null;
|
|
setTimeout(() => {
|
|
isDragging = false;
|
|
}, 100);
|
|
}
|
|
|
|
async function handleMove(docId: string, newParentId: string | null) {
|
|
documents = documents.map((d) =>
|
|
d.id === docId ? { ...d, parent_id: newParentId } : d,
|
|
);
|
|
try {
|
|
await moveDocument(supabase, docId, newParentId);
|
|
} catch {
|
|
toasts.error("Failed to move file");
|
|
const { data: freshDocs } = await supabase
|
|
.from("documents")
|
|
.select(
|
|
"id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id",
|
|
)
|
|
.eq("org_id", org.id)
|
|
.order("name");
|
|
if (freshDocs) documents = freshDocs as Document[];
|
|
}
|
|
}
|
|
|
|
async function handleCreate() {
|
|
if (!newDocName.trim() || !user) return;
|
|
|
|
try {
|
|
if (newDocType === "kanban") {
|
|
// Create kanban board first, then link as document
|
|
const { data: newBoard, error: boardError } = await supabase
|
|
.from("kanban_boards")
|
|
.insert({ org_id: org.id, name: newDocName })
|
|
.select()
|
|
.single();
|
|
if (boardError || !newBoard) {
|
|
toasts.error("Failed to create kanban board");
|
|
return;
|
|
}
|
|
await supabase.from("kanban_columns").insert([
|
|
{ board_id: newBoard.id, name: "To Do", position: 0 },
|
|
{ board_id: newBoard.id, name: "In Progress", position: 1 },
|
|
{ board_id: newBoard.id, name: "Done", position: 2 },
|
|
]);
|
|
const newDoc = await createDocument(
|
|
supabase,
|
|
org.id,
|
|
newDocName,
|
|
"kanban",
|
|
currentFolderId,
|
|
user.id,
|
|
{
|
|
id: newBoard.id,
|
|
content: {
|
|
type: "kanban",
|
|
board_id: newBoard.id,
|
|
} as import("$lib/supabase/types").Json,
|
|
},
|
|
);
|
|
logActivity(supabase, {
|
|
orgId: org.id,
|
|
userId: user.id,
|
|
action: "create",
|
|
entityType: "kanban_board",
|
|
entityId: newDoc.id,
|
|
entityName: newDocName,
|
|
});
|
|
goto(getFileUrl(newDoc));
|
|
} else {
|
|
const newDoc = await createDocument(
|
|
supabase,
|
|
org.id,
|
|
newDocName,
|
|
newDocType,
|
|
currentFolderId,
|
|
user.id,
|
|
);
|
|
documents = [...documents, newDoc];
|
|
logActivity(supabase, {
|
|
orgId: org.id,
|
|
userId: user.id,
|
|
action: "create",
|
|
entityType: newDocType === "folder" ? "folder" : "document",
|
|
entityId: newDoc.id,
|
|
entityName: newDocName,
|
|
});
|
|
if (newDocType === "document") {
|
|
goto(getFileUrl(newDoc));
|
|
}
|
|
}
|
|
} catch {
|
|
toasts.error("Failed to create document");
|
|
}
|
|
|
|
showCreateModal = false;
|
|
newDocName = "";
|
|
newDocType = "document";
|
|
}
|
|
|
|
async function handleSave(content: import("$lib/supabase/types").Json) {
|
|
if (!selectedDoc) return;
|
|
try {
|
|
await updateDocument(supabase, selectedDoc.id, { content });
|
|
documents = documents.map((d) =>
|
|
d.id === selectedDoc!.id ? { ...d, content } : d,
|
|
);
|
|
} catch {
|
|
toasts.error("Failed to save document");
|
|
}
|
|
}
|
|
|
|
async function handleRename() {
|
|
if (!editingDoc || !newDocName.trim()) return;
|
|
try {
|
|
await updateDocument(supabase, editingDoc.id, { name: newDocName });
|
|
if (user) {
|
|
logActivity(supabase, {
|
|
orgId: org.id,
|
|
userId: user.id,
|
|
action: "rename",
|
|
entityType:
|
|
editingDoc.type === "folder" ? "folder" : "document",
|
|
entityId: editingDoc.id,
|
|
entityName: newDocName,
|
|
});
|
|
}
|
|
documents = documents.map((d) =>
|
|
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
|
|
);
|
|
if (selectedDoc?.id === editingDoc.id) {
|
|
selectedDoc = { ...selectedDoc, name: newDocName };
|
|
}
|
|
} catch {
|
|
toasts.error("Failed to rename document");
|
|
}
|
|
showEditModal = false;
|
|
editingDoc = null;
|
|
newDocName = "";
|
|
}
|
|
|
|
async function handleDelete(doc: Document) {
|
|
const itemType =
|
|
doc.type === "folder" ? "folder and all its contents" : "document";
|
|
if (!confirm(`Delete this ${itemType}?`)) return;
|
|
|
|
// Recursively collect all descendant IDs for proper deletion
|
|
function collectDescendantIds(parentId: string): string[] {
|
|
const children = documents.filter((d) => d.parent_id === parentId);
|
|
let ids: string[] = [];
|
|
for (const child of children) {
|
|
ids.push(child.id);
|
|
if (child.type === "folder") {
|
|
ids = ids.concat(collectDescendantIds(child.id));
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
try {
|
|
if (doc.type === "folder") {
|
|
const descendantIds = collectDescendantIds(doc.id);
|
|
for (const id of descendantIds) {
|
|
await deleteDocument(supabase, id);
|
|
}
|
|
}
|
|
await deleteDocument(supabase, doc.id);
|
|
|
|
if (user) {
|
|
logActivity(supabase, {
|
|
orgId: org.id,
|
|
userId: user.id,
|
|
action: "delete",
|
|
entityType:
|
|
doc.type === "folder"
|
|
? "folder"
|
|
: doc.type === "kanban"
|
|
? "kanban_board"
|
|
: "document",
|
|
entityId: doc.id,
|
|
entityName: doc.name,
|
|
});
|
|
}
|
|
const deletedIds = new Set([
|
|
doc.id,
|
|
...(doc.type === "folder" ? collectDescendantIds(doc.id) : []),
|
|
]);
|
|
documents = documents.filter((d) => !deletedIds.has(d.id));
|
|
if (selectedDoc?.id === doc.id) {
|
|
selectedDoc = null;
|
|
}
|
|
} catch {
|
|
toasts.error("Failed to delete document");
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="flex h-full gap-0">
|
|
<!-- Files Panel -->
|
|
<div class="flex flex-col flex-1 min-w-0 h-full overflow-hidden">
|
|
<!-- Toolbar: Breadcrumbs + Actions -->
|
|
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
|
|
<!-- Breadcrumb Path -->
|
|
<nav class="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
|
|
{#each breadcrumbPath as crumb, i}
|
|
{#if i > 0}
|
|
<span
|
|
class="material-symbols-rounded text-light/20 shrink-0"
|
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
|
>
|
|
chevron_right
|
|
</span>
|
|
{/if}
|
|
<a
|
|
href={getFolderUrl(crumb.id)}
|
|
class="px-2 py-1 rounded-lg text-body-sm font-body whitespace-nowrap transition-colors
|
|
{crumb.id === currentFolderId
|
|
? 'text-white bg-dark/30'
|
|
: 'text-light/50 hover:text-white hover:bg-dark/30'}
|
|
{dragOverBreadcrumb === (crumb.id ?? '__root__')
|
|
? 'ring-2 ring-primary bg-primary/10'
|
|
: ''}"
|
|
ondragover={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
dragOverBreadcrumb = crumb.id ?? "__root__";
|
|
}}
|
|
ondragleave={() => {
|
|
dragOverBreadcrumb = undefined;
|
|
}}
|
|
ondrop={async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragOverBreadcrumb = undefined;
|
|
if (!draggedItem) return;
|
|
if (draggedItem.parent_id === crumb.id) {
|
|
resetDragState();
|
|
return;
|
|
}
|
|
const draggedName = draggedItem.name;
|
|
await handleMove(draggedItem.id, crumb.id);
|
|
toasts.success(
|
|
`Moved "${draggedName}" to "${crumb.name}"`,
|
|
);
|
|
resetDragState();
|
|
}}
|
|
>
|
|
{#if i === 0}
|
|
<span class="material-symbols-rounded" style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;">home</span>
|
|
{:else}
|
|
{crumb.name}
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
</nav>
|
|
|
|
<Button size="sm" icon="add" onclick={handleAdd}>{m.btn_new()}</Button>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 rounded-lg text-light/40 hover:text-white hover:bg-dark/50 transition-colors"
|
|
title={m.files_toggle_view()}
|
|
onclick={toggleViewMode}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>{viewMode === "list" ? "grid_view" : "view_list"}</span
|
|
>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- File List/Grid -->
|
|
<div class="flex-1 overflow-auto min-h-0 p-4">
|
|
{#if viewMode === "list"}
|
|
<div
|
|
class="flex flex-col gap-0.5"
|
|
ondragover={handleContainerDragOver}
|
|
ondrop={handleDropOnEmpty}
|
|
role="list"
|
|
>
|
|
{#if currentFolderItems.length === 0}
|
|
<div class="flex flex-col items-center justify-center text-light/40 py-16">
|
|
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">folder_open</span>
|
|
<p class="text-body-sm">{m.files_empty()}</p>
|
|
</div>
|
|
{:else}
|
|
{#each currentFolderItems as item}
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-3 px-3 py-2 rounded-xl w-full text-left transition-colors hover:bg-dark/50
|
|
{selectedDoc?.id === item.id ? 'bg-dark/50 ring-1 ring-primary/20' : ''}
|
|
{draggedItem?.id === item.id ? 'opacity-50' : ''}
|
|
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
|
draggable="true"
|
|
ondragstart={(e) => handleDragStart(e, item)}
|
|
ondragend={handleDragEnd}
|
|
ondragover={(e) => handleDragOver(e, item)}
|
|
ondragleave={handleDragLeave}
|
|
ondrop={(e) => handleDrop(e, item)}
|
|
onclick={() => handleItemClick(item)}
|
|
ondblclick={() => handleDoubleClick(item)}
|
|
onauxclick={(e) => handleAuxClick(e, item)}
|
|
oncontextmenu={(e) =>
|
|
handleContextMenu(e, item)}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded shrink-0 {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/50'}"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>
|
|
{getDocIcon(item)}
|
|
</span>
|
|
<span
|
|
class="font-body text-body-sm text-white truncate flex-1"
|
|
>{item.name}</span
|
|
>
|
|
{#if item.type === "folder"}
|
|
<span
|
|
class="material-symbols-rounded text-light/20"
|
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
|
>
|
|
chevron_right
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- Grid View -->
|
|
<div
|
|
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2"
|
|
ondragover={handleContainerDragOver}
|
|
ondrop={handleDropOnEmpty}
|
|
role="list"
|
|
>
|
|
{#if currentFolderItems.length === 0}
|
|
<div class="col-span-full flex flex-col items-center justify-center text-light/40 py-16">
|
|
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">folder_open</span>
|
|
<p class="text-body-sm">{m.files_empty()}</p>
|
|
</div>
|
|
{:else}
|
|
{#each currentFolderItems as item}
|
|
<button
|
|
type="button"
|
|
class="flex flex-col items-center gap-2 p-3 rounded-xl border border-transparent transition-all hover:bg-dark/50 hover:border-light/5
|
|
{selectedDoc?.id === item.id ? 'bg-dark/50 border-primary/20' : ''}
|
|
{draggedItem?.id === item.id ? 'opacity-50' : ''}
|
|
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
|
draggable="true"
|
|
ondragstart={(e) => handleDragStart(e, item)}
|
|
ondragend={handleDragEnd}
|
|
ondragover={(e) => handleDragOver(e, item)}
|
|
ondragleave={handleDragLeave}
|
|
ondrop={(e) => handleDrop(e, item)}
|
|
onclick={() => handleItemClick(item)}
|
|
ondblclick={() => handleDoubleClick(item)}
|
|
onauxclick={(e) => handleAuxClick(e, item)}
|
|
oncontextmenu={(e) =>
|
|
handleContextMenu(e, item)}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/40'}"
|
|
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
|
|
>
|
|
{getDocIcon(item)}
|
|
</span>
|
|
<span
|
|
class="font-body text-[12px] text-white text-center truncate w-full"
|
|
>{item.name}</span
|
|
>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Compact Editor Panel (shown when a doc is selected) -->
|
|
{#if selectedDoc}
|
|
<div class="flex-1 min-w-0 h-full border-l border-light/5">
|
|
<DocumentViewer
|
|
document={selectedDoc}
|
|
onSave={handleSave}
|
|
mode="preview"
|
|
editUrl={getFileUrl(selectedDoc)}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={showCreateModal}
|
|
onClose={() => (showCreateModal = false)}
|
|
title={m.files_create_title()}
|
|
>
|
|
<div class="space-y-4">
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
|
'document'
|
|
? 'border-primary bg-primary/10'
|
|
: 'border-light/20'}"
|
|
onclick={() => (newDocType = "document")}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-h4 mr-1"
|
|
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>description</span
|
|
>
|
|
{m.files_type_document()}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
|
'folder'
|
|
? 'border-primary bg-primary/10'
|
|
: 'border-light/20'}"
|
|
onclick={() => (newDocType = "folder")}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-h4 mr-1"
|
|
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>folder</span
|
|
>
|
|
{m.files_type_folder()}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
|
'kanban'
|
|
? 'border-primary bg-primary/10'
|
|
: 'border-light/20'}"
|
|
onclick={() => (newDocType = "kanban")}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-h4 mr-1"
|
|
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>view_kanban</span
|
|
>
|
|
{m.files_type_kanban()}
|
|
</button>
|
|
</div>
|
|
<Input
|
|
label={m.files_name_label()}
|
|
bind:value={newDocName}
|
|
placeholder={newDocType === "folder"
|
|
? m.files_folder_placeholder()
|
|
: newDocType === "kanban"
|
|
? m.files_kanban_placeholder()
|
|
: m.files_doc_placeholder()}
|
|
/>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
|
|
>{m.btn_cancel()}</Button
|
|
>
|
|
<Button onclick={handleCreate} disabled={!newDocName.trim()}
|
|
>{m.btn_create()}</Button
|
|
>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<!-- Context Menu -->
|
|
{#if contextMenu}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="fixed inset-0 z-50" onclick={closeContextMenu}></div>
|
|
<div
|
|
class="fixed z-50 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[200px]"
|
|
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
|
|
onclick={contextRename}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-light"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>edit</span
|
|
>
|
|
{m.files_context_rename()}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
|
|
onclick={contextCopy}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-light"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>content_copy</span
|
|
>
|
|
Make a copy
|
|
</button>
|
|
<div class="relative">
|
|
<button
|
|
type="button"
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
|
|
onclick={contextOrganize}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-light"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>drive_file_move</span
|
|
>
|
|
Organize
|
|
<span
|
|
class="material-symbols-rounded text-light/50 ml-auto"
|
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
|
>chevron_right</span
|
|
>
|
|
</button>
|
|
{#if showOrganizeMenu}
|
|
<div
|
|
class="absolute left-full top-0 ml-1 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[180px] max-h-[240px] overflow-auto"
|
|
>
|
|
{#if contextMenu.doc.parent_id !== null}
|
|
<button
|
|
type="button"
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
|
|
onclick={() => contextMoveToFolder(null)}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-light"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>home</span
|
|
>
|
|
Home
|
|
</button>
|
|
{/if}
|
|
{#each availableFolders as folder}
|
|
{#if folder.id !== contextMenu.doc.parent_id}
|
|
<button
|
|
type="button"
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
|
|
onclick={() => contextMoveToFolder(folder.id)}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded text-light"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>folder</span
|
|
>
|
|
{folder.name}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="border-t border-light/10 my-1"></div>
|
|
<button
|
|
type="button"
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-error hover:bg-error/10 transition-colors"
|
|
onclick={contextDelete}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
>delete</span
|
|
>
|
|
{m.files_context_delete()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<Modal
|
|
isOpen={showEditModal}
|
|
onClose={() => {
|
|
showEditModal = false;
|
|
editingDoc = null;
|
|
newDocName = "";
|
|
}}
|
|
title={m.files_rename_title()}
|
|
>
|
|
<div class="space-y-4">
|
|
<Input
|
|
label={m.files_name_label()}
|
|
bind:value={newDocName}
|
|
placeholder={m.files_name_label()}
|
|
/>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
variant="tertiary"
|
|
onclick={() => {
|
|
showEditModal = false;
|
|
editingDoc = null;
|
|
newDocName = "";
|
|
}}>{m.btn_cancel()}</Button
|
|
>
|
|
<Button onclick={handleRename} disabled={!newDocName.trim()}
|
|
>{m.btn_save()}</Button
|
|
>
|
|
</div>
|
|
</div>
|
|
</Modal>
|