Mega push vol 4
This commit is contained in:
874
src/lib/components/documents/FileBrowser.svelte
Normal file
874
src/lib/components/documents/FileBrowser.svelte
Normal file
@@ -0,0 +1,874 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Icon,
|
||||
} 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";
|
||||
|
||||
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 = "Files",
|
||||
}: 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">("grid");
|
||||
|
||||
// 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();
|
||||
const { data: newDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
org_id: org.id,
|
||||
name: `${doc.name} (copy)`,
|
||||
type: doc.type,
|
||||
parent_id: doc.parent_id,
|
||||
created_by: user.id,
|
||||
content: doc.content,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (!error && newDoc) {
|
||||
documents = [...documents, newDoc as Document];
|
||||
toasts.success(`Copied "${doc.name}"`);
|
||||
} else if (error) {
|
||||
log.error("Failed to copy document", { error });
|
||||
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,
|
||||
);
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
parent_id: newParentId,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", docId);
|
||||
if (error) {
|
||||
log.error("Failed to move document", {
|
||||
error,
|
||||
data: { docId, newParentId },
|
||||
});
|
||||
toasts.error("Failed to move file");
|
||||
const { data: freshDocs } = await supabase
|
||||
.from("documents")
|
||||
.select("*")
|
||||
.eq("org_id", org.id)
|
||||
.order("name");
|
||||
if (freshDocs) documents = freshDocs as Document[];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newDocName.trim() || !user) return;
|
||||
|
||||
if (newDocType === "kanban") {
|
||||
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 { data: newDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
id: newBoard.id,
|
||||
org_id: org.id,
|
||||
name: newDocName,
|
||||
type: "kanban",
|
||||
parent_id: currentFolderId,
|
||||
created_by: user.id,
|
||||
content: {
|
||||
type: "kanban",
|
||||
board_id: newBoard.id,
|
||||
} as import("$lib/supabase/types").Json,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (!error && newDoc) {
|
||||
goto(getFileUrl(newDoc as Document));
|
||||
} else if (error) {
|
||||
toasts.error("Failed to create kanban document");
|
||||
}
|
||||
} else {
|
||||
let content: any = null;
|
||||
if (newDocType === "document") {
|
||||
content = { type: "doc", content: [] };
|
||||
}
|
||||
const { data: newDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
org_id: org.id,
|
||||
name: newDocName,
|
||||
type: newDocType as "folder" | "document",
|
||||
parent_id: currentFolderId,
|
||||
created_by: user.id,
|
||||
content,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (!error && newDoc) {
|
||||
documents = [...documents, newDoc as Document];
|
||||
if (newDocType === "document") {
|
||||
goto(getFileUrl(newDoc as Document));
|
||||
}
|
||||
} else if (error) {
|
||||
toasts.error("Failed to create document");
|
||||
}
|
||||
}
|
||||
|
||||
showCreateModal = false;
|
||||
newDocName = "";
|
||||
newDocType = "document";
|
||||
}
|
||||
|
||||
async function handleSave(content: import("$lib/supabase/types").Json) {
|
||||
if (!selectedDoc) return;
|
||||
await supabase
|
||||
.from("documents")
|
||||
.update({ content, updated_at: new Date().toISOString() })
|
||||
.eq("id", selectedDoc.id);
|
||||
documents = documents.map((d) =>
|
||||
d.id === selectedDoc!.id ? { ...d, content } : d,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!editingDoc || !newDocName.trim()) return;
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ name: newDocName, updated_at: new Date().toISOString() })
|
||||
.eq("id", editingDoc.id);
|
||||
if (!error) {
|
||||
documents = documents.map((d) =>
|
||||
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
|
||||
);
|
||||
if (selectedDoc?.id === editingDoc.id) {
|
||||
selectedDoc = { ...selectedDoc, name: newDocName };
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (doc.type === "folder") {
|
||||
const descendantIds = collectDescendantIds(doc.id);
|
||||
if (descendantIds.length > 0) {
|
||||
await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.in("id", descendantIds);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("id", doc.id);
|
||||
if (!error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full gap-4">
|
||||
<!-- Files Panel -->
|
||||
<div
|
||||
class="bg-night rounded-[32px] flex flex-col gap-4 px-4 py-5 overflow-hidden flex-1 min-w-0 h-full"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="flex items-center gap-2 p-1">
|
||||
<Avatar name={title} size="md" />
|
||||
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
|
||||
<Button size="md" onclick={handleAdd}>+ New</Button>
|
||||
<IconButton
|
||||
title="Toggle view"
|
||||
onclick={() =>
|
||||
(viewMode = viewMode === "list" ? "grid" : "list")}
|
||||
>
|
||||
<Icon
|
||||
name={viewMode === "list" ? "grid_view" : "view_list"}
|
||||
size={24}
|
||||
/>
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<!-- Breadcrumb Path -->
|
||||
<nav class="flex items-center gap-2 text-h3 font-heading">
|
||||
{#each breadcrumbPath as crumb, i}
|
||||
{#if i > 0}
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
{/if}
|
||||
<a
|
||||
href={getFolderUrl(crumb.id)}
|
||||
class="px-3 py-1 rounded-xl transition-colors
|
||||
{crumb.id === currentFolderId
|
||||
? 'text-white'
|
||||
: 'text-light/60 hover:text-primary'}
|
||||
{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();
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- File List/Grid -->
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
{#if viewMode === "list"}
|
||||
<div
|
||||
class="flex flex-col gap-1"
|
||||
ondragover={handleContainerDragOver}
|
||||
ondrop={handleDropOnEmpty}
|
||||
role="list"
|
||||
>
|
||||
{#if currentFolderItems.length === 0}
|
||||
<div class="text-center text-light/40 py-8 text-sm">
|
||||
<p>
|
||||
No files yet. Drag files here or create a new
|
||||
one.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each currentFolderItems as item}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] w-full text-left transition-colors hover:bg-dark
|
||||
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
|
||||
{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)}
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center p-1"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>
|
||||
{getDocIcon(item)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="font-body text-body text-white truncate flex-1"
|
||||
>{item.name}</span
|
||||
>
|
||||
{#if item.type === "folder"}
|
||||
<span
|
||||
class="material-symbols-rounded text-light/50"
|
||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||
>
|
||||
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-4"
|
||||
ondragover={handleContainerDragOver}
|
||||
ondrop={handleDropOnEmpty}
|
||||
role="list"
|
||||
>
|
||||
{#if currentFolderItems.length === 0}
|
||||
<div
|
||||
class="col-span-full text-center text-light/40 py-8 text-sm"
|
||||
>
|
||||
<p>
|
||||
No files yet. Drag files here or create a new
|
||||
one.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each currentFolderItems as item}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-xl transition-colors hover:bg-dark
|
||||
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
|
||||
{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 text-light"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||
>
|
||||
{getDocIcon(item)}
|
||||
</span>
|
||||
<span
|
||||
class="font-body text-body-md 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">
|
||||
<DocumentViewer
|
||||
document={selectedDoc}
|
||||
onSave={handleSave}
|
||||
mode="preview"
|
||||
editUrl={getFileUrl(selectedDoc)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => (showCreateModal = false)}
|
||||
title="Create New"
|
||||
>
|
||||
<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
|
||||
>
|
||||
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
|
||||
>
|
||||
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
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newDocName}
|
||||
placeholder={newDocType === "folder"
|
||||
? "Folder name"
|
||||
: newDocType === "kanban"
|
||||
? "Kanban board name"
|
||||
: "Document name"}
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleCreate} disabled={!newDocName.trim()}
|
||||
>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
|
||||
>
|
||||
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
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
newDocName = "";
|
||||
}}
|
||||
title="Rename"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newDocName}
|
||||
placeholder="Enter new name"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => {
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
newDocName = "";
|
||||
}}>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleRename} disabled={!newDocName.trim()}
|
||||
>Save</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
Reference in New Issue
Block a user