Mega push vol 4

This commit is contained in:
AlacrisDevs
2026-02-06 16:08:40 +02:00
parent b517bb975c
commit d8bbfd9dc3
95 changed files with 8019 additions and 3946 deletions

View 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>