Mega push vol 4
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.documents');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org } = await parent();
|
||||
const { supabase } = locals;
|
||||
|
||||
const { data: documents } = await supabase
|
||||
const { data: documents, error } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.order('name');
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to load documents', { error, data: { orgId: org.id } });
|
||||
}
|
||||
|
||||
log.debug('Documents loaded', { data: { count: documents?.length ?? 0 } });
|
||||
|
||||
return {
|
||||
documents: documents ?? []
|
||||
};
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Modal, Input } from "$lib/components/ui";
|
||||
import { FileTree, Editor } from "$lib/components/documents";
|
||||
import { buildDocumentTree } from "$lib/api/documents";
|
||||
import { FileBrowser } from "$lib/components/documents";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -17,326 +12,21 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let documents = $state(data.documents);
|
||||
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">("document");
|
||||
let parentFolderId = $state<string | null>(null);
|
||||
let isEditing = $state(false);
|
||||
|
||||
const documentTree = $derived(buildDocumentTree(documents));
|
||||
|
||||
function handleSelect(doc: Document) {
|
||||
if (doc.type === "document") {
|
||||
selectedDoc = doc;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDoubleClick(doc: Document) {
|
||||
if (doc.type === "document") {
|
||||
// Open document in new window
|
||||
const url = `/${data.org.slug}/documents/${doc.id}`;
|
||||
window.open(url, "_blank", "width=900,height=700");
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd(folderId: string | null) {
|
||||
parentFolderId = folderId;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
async function handleMove(docId: string, newParentId: string | null) {
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
parent_id: newParentId,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", docId);
|
||||
|
||||
if (!error) {
|
||||
documents = documents.map((d) =>
|
||||
d.id === docId ? { ...d, parent_id: newParentId } : d,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newDocName.trim() || !data.user) return;
|
||||
|
||||
const { data: newDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
org_id: data.org.id,
|
||||
name: newDocName,
|
||||
type: newDocType,
|
||||
parent_id: parentFolderId,
|
||||
created_by: data.user.id,
|
||||
content:
|
||||
newDocType === "document"
|
||||
? { type: "doc", content: [] }
|
||||
: null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && newDoc) {
|
||||
documents = [...documents, newDoc];
|
||||
if (newDocType === "document") {
|
||||
selectedDoc = newDoc;
|
||||
}
|
||||
}
|
||||
|
||||
showCreateModal = false;
|
||||
newDocName = "";
|
||||
newDocType = "document";
|
||||
parentFolderId = null;
|
||||
}
|
||||
|
||||
async function handleSave(content: unknown) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
function handleEdit(doc: Document) {
|
||||
editingDoc = doc;
|
||||
newDocName = doc.name;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// If deleting a folder, delete all children first
|
||||
if (doc.type === "folder") {
|
||||
const childIds = documents
|
||||
.filter((d) => d.parent_id === doc.id)
|
||||
.map((d) => d.id);
|
||||
if (childIds.length > 0) {
|
||||
await supabase.from("documents").delete().in("id", childIds);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("id", doc.id);
|
||||
|
||||
if (!error) {
|
||||
documents = documents.filter(
|
||||
(d) => d.id !== doc.id && d.parent_id !== doc.id,
|
||||
);
|
||||
if (selectedDoc?.id === doc.id) {
|
||||
selectedDoc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
documents = data.documents;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title
|
||||
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org
|
||||
.name} | Root</title
|
||||
>
|
||||
<title>Files - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-full">
|
||||
<aside class="w-72 border-r border-light/10 flex flex-col">
|
||||
<div
|
||||
class="p-4 border-b border-light/10 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="font-semibold text-light">Documents</h2>
|
||||
<Button size="sm" onclick={() => (showCreateModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
{#if documentTree.length === 0}
|
||||
<div class="text-center text-light/40 py-8 text-sm">
|
||||
<p>No documents yet</p>
|
||||
<p class="mt-1">Create your first document</p>
|
||||
</div>
|
||||
{:else}
|
||||
<FileTree
|
||||
items={documentTree}
|
||||
selectedId={selectedDoc?.id ?? null}
|
||||
onSelect={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onAdd={handleAdd}
|
||||
onMove={handleMove}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-hidden flex flex-col">
|
||||
{#if selectedDoc}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border-b border-light/10"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-light">
|
||||
{selectedDoc.name}
|
||||
</h2>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {isEditing
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-light/10 text-light hover:bg-light/20'}"
|
||||
onclick={() => (isEditing = !isEditing)}
|
||||
>
|
||||
{isEditing ? "Preview" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<Editor
|
||||
document={selectedDoc}
|
||||
onSave={handleSave}
|
||||
editable={isEditing}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
<p>Select a document to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<div class="h-full p-4 lg:p-5">
|
||||
<FileBrowser
|
||||
org={data.org}
|
||||
bind:documents
|
||||
currentFolderId={null}
|
||||
user={data.user}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => (showCreateModal = false)}
|
||||
title="Create New"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<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")}
|
||||
>
|
||||
Document
|
||||
</button>
|
||||
<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")}
|
||||
>
|
||||
Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newDocName}
|
||||
placeholder={newDocType === "folder"
|
||||
? "Folder name"
|
||||
: "Document name"}
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleCreate} disabled={!newDocName.trim()}
|
||||
>Create</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<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="ghost"
|
||||
onclick={() => {
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
newDocName = "";
|
||||
}}>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleRename} disabled={!newDocName.trim()}
|
||||
>Save</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal file
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.document');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals, params }) => {
|
||||
const { org } = await parent() as { org: { id: string; slug: string } };
|
||||
const { supabase } = locals;
|
||||
const { id } = params;
|
||||
|
||||
log.debug('Redirecting document by ID', { data: { id, orgId: org.id } });
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('documents')
|
||||
.select('type')
|
||||
.eq('org_id', org.id)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (docError || !document) {
|
||||
log.error('Document not found', { error: docError, data: { id, orgId: org.id } });
|
||||
throw error(404, 'Document not found');
|
||||
}
|
||||
|
||||
if (document.type === 'folder') {
|
||||
throw redirect(302, `/${org.slug}/documents/folder/${id}`);
|
||||
}
|
||||
|
||||
throw redirect(302, `/${org.slug}/documents/file/${id}`);
|
||||
};
|
||||
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal file
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- This route redirects to /folder/[id] or /file/[id] via +page.server.ts -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span
|
||||
class="material-symbols-rounded text-primary animate-spin"
|
||||
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal file
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.file');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals, params }) => {
|
||||
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
|
||||
const { supabase } = locals;
|
||||
const { id } = params;
|
||||
|
||||
log.debug('Loading file by ID', { data: { id, orgId: org.id } });
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (docError || !document) {
|
||||
log.error('File not found', { error: docError, data: { id, orgId: org.id } });
|
||||
throw error(404, 'File not found');
|
||||
}
|
||||
|
||||
if (document.type === 'folder') {
|
||||
throw redirect(302, `/${org.slug}/documents/folder/${id}`);
|
||||
}
|
||||
|
||||
const isKanban = document.type === 'kanban';
|
||||
|
||||
return {
|
||||
document,
|
||||
isKanban,
|
||||
isFolder: false,
|
||||
children: [],
|
||||
user
|
||||
};
|
||||
};
|
||||
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal file
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal file
@@ -0,0 +1,572 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, onMount } from "svelte";
|
||||
import { Button, Modal, Input } from "$lib/components/ui";
|
||||
import { DocumentViewer } from "$lib/components/documents";
|
||||
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
|
||||
import {
|
||||
fetchBoardWithColumns,
|
||||
createColumn,
|
||||
moveCard,
|
||||
deleteCard,
|
||||
deleteColumn,
|
||||
subscribeToBoard,
|
||||
} from "$lib/api/kanban";
|
||||
import {
|
||||
getLockInfo,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
startHeartbeat,
|
||||
type LockInfo,
|
||||
} from "$lib/api/document-locks";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import type {
|
||||
RealtimeChannel,
|
||||
SupabaseClient,
|
||||
} from "@supabase/supabase-js";
|
||||
import type { Database, KanbanCard, Document } from "$lib/supabase/types";
|
||||
import type { BoardWithColumns } from "$lib/api/kanban";
|
||||
|
||||
const log = createLogger("page.file-viewer");
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
document: Document;
|
||||
isKanban: boolean;
|
||||
isFolder: boolean;
|
||||
children: any[];
|
||||
user: { id: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Document lock state
|
||||
let lockInfo = $state<LockInfo>({
|
||||
isLocked: false,
|
||||
lockedBy: null,
|
||||
lockedByName: null,
|
||||
isOwnLock: false,
|
||||
});
|
||||
let hasLock = $state(false);
|
||||
let stopHeartbeat: (() => void) | null = null;
|
||||
|
||||
// Acquire lock for document editing (not for kanban)
|
||||
onMount(async () => {
|
||||
if (data.isKanban || !data.user) return;
|
||||
|
||||
// Check current lock status
|
||||
lockInfo = await getLockInfo(supabase, data.document.id, data.user.id);
|
||||
|
||||
if (lockInfo.isLocked && !lockInfo.isOwnLock) {
|
||||
// Someone else is editing
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
const acquired = await acquireLock(
|
||||
supabase,
|
||||
data.document.id,
|
||||
data.user.id,
|
||||
);
|
||||
if (acquired) {
|
||||
hasLock = true;
|
||||
stopHeartbeat = startHeartbeat(
|
||||
supabase,
|
||||
data.document.id,
|
||||
data.user.id,
|
||||
);
|
||||
} else {
|
||||
// Refresh lock info to get who holds it
|
||||
lockInfo = await getLockInfo(
|
||||
supabase,
|
||||
data.document.id,
|
||||
data.user.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Kanban state
|
||||
let kanbanBoard = $state<BoardWithColumns | null>(null);
|
||||
let realtimeChannel = $state<RealtimeChannel | null>(null);
|
||||
let showCardModal = $state(false);
|
||||
let selectedCard = $state<KanbanCard | null>(null);
|
||||
let targetColumnId = $state<string | null>(null);
|
||||
let cardModalMode = $state<"edit" | "create">("edit");
|
||||
let showAddColumnModal = $state(false);
|
||||
let newColumnName = $state("");
|
||||
|
||||
async function handleSave(content: import("$lib/supabase/types").Json) {
|
||||
isSaving = true;
|
||||
try {
|
||||
await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
content,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", data.document.id);
|
||||
} catch (err) {
|
||||
log.error("Failed to save document", { error: err });
|
||||
toasts.error("Failed to save document");
|
||||
}
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
// Kanban functions
|
||||
async function loadKanbanBoard() {
|
||||
if (!data.isKanban) return;
|
||||
try {
|
||||
const content = data.document.content as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const boardId = (content?.board_id as string) || data.document.id;
|
||||
|
||||
let board = await fetchBoardWithColumns(supabase, boardId).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!board) {
|
||||
log.info("Auto-creating kanban_boards entry for document", {
|
||||
data: { boardId, docId: data.document.id },
|
||||
});
|
||||
|
||||
const { data: newBoard, error: createErr } = await supabase
|
||||
.from("kanban_boards")
|
||||
.insert({
|
||||
id: data.document.id,
|
||||
org_id: data.org.id,
|
||||
name: data.document.name,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createErr) {
|
||||
log.error("Failed to auto-create kanban board", {
|
||||
error: createErr,
|
||||
});
|
||||
toasts.error("Failed to load kanban board");
|
||||
return;
|
||||
}
|
||||
|
||||
await supabase.from("kanban_columns").insert([
|
||||
{ board_id: data.document.id, name: "To Do", position: 0 },
|
||||
{
|
||||
board_id: data.document.id,
|
||||
name: "In Progress",
|
||||
position: 1,
|
||||
},
|
||||
{ board_id: data.document.id, name: "Done", position: 2 },
|
||||
]);
|
||||
|
||||
await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
content: {
|
||||
type: "kanban",
|
||||
board_id: data.document.id,
|
||||
} as import("$lib/supabase/types").Json,
|
||||
})
|
||||
.eq("id", data.document.id);
|
||||
|
||||
board = await fetchBoardWithColumns(
|
||||
supabase,
|
||||
data.document.id,
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
kanbanBoard = board;
|
||||
} catch (err) {
|
||||
log.error("Failed to load kanban board", { error: err });
|
||||
toasts.error("Failed to load kanban board");
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (data.isKanban) {
|
||||
loadKanbanBoard();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!kanbanBoard) return;
|
||||
|
||||
const channel = subscribeToBoard(
|
||||
supabase,
|
||||
kanbanBoard.id,
|
||||
() => loadKanbanBoard(),
|
||||
() => loadKanbanBoard(),
|
||||
);
|
||||
realtimeChannel = channel;
|
||||
|
||||
return () => {
|
||||
if (channel) {
|
||||
supabase.removeChannel(channel);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (realtimeChannel) {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
}
|
||||
// Release document lock
|
||||
if (hasLock && data.user) {
|
||||
stopHeartbeat?.();
|
||||
releaseLock(supabase, data.document.id, data.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCardMove(
|
||||
cardId: string,
|
||||
toColumnId: string,
|
||||
toPosition: number,
|
||||
) {
|
||||
try {
|
||||
await moveCard(supabase, cardId, toColumnId, toPosition);
|
||||
} catch (err) {
|
||||
log.error("Failed to move card", { error: err });
|
||||
toasts.error("Failed to move card");
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(card: KanbanCard) {
|
||||
selectedCard = card;
|
||||
cardModalMode = "edit";
|
||||
showCardModal = true;
|
||||
}
|
||||
|
||||
function handleAddCard(columnId: string) {
|
||||
targetColumnId = columnId;
|
||||
selectedCard = null;
|
||||
cardModalMode = "create";
|
||||
showCardModal = true;
|
||||
}
|
||||
|
||||
async function handleAddColumn() {
|
||||
if (!kanbanBoard || !newColumnName.trim()) return;
|
||||
try {
|
||||
await createColumn(
|
||||
supabase,
|
||||
kanbanBoard.id,
|
||||
newColumnName,
|
||||
kanbanBoard.columns.length,
|
||||
);
|
||||
newColumnName = "";
|
||||
showAddColumnModal = false;
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("Failed to add column", { error: err });
|
||||
toasts.error("Failed to add column");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteColumn(columnId: string) {
|
||||
if (!confirm("Delete this column and all its cards?")) return;
|
||||
try {
|
||||
await deleteColumn(supabase, columnId);
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("Failed to delete column", { error: err });
|
||||
toasts.error("Failed to delete column");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCard(cardId: string) {
|
||||
try {
|
||||
await deleteCard(supabase, cardId);
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("Failed to delete card", { error: err });
|
||||
toasts.error("Failed to delete card");
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Import for kanban board
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let isImporting = $state(false);
|
||||
|
||||
function triggerImport() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function handleJsonImport(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !kanbanBoard) return;
|
||||
|
||||
isImporting = true;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
|
||||
// Support two formats:
|
||||
// 1. Full board export: { columns: [{ name, cards: [{ title, description, ... }] }] }
|
||||
// 2. Flat card list: [{ title, description, column?, ... }]
|
||||
if (Array.isArray(json)) {
|
||||
// Flat card list — add all to first column
|
||||
const firstCol = kanbanBoard.columns[0];
|
||||
if (!firstCol) {
|
||||
toasts.error("No columns exist to import cards into");
|
||||
return;
|
||||
}
|
||||
let pos = firstCol.cards.length;
|
||||
for (const card of json) {
|
||||
await supabase.from("kanban_cards").insert({
|
||||
column_id: firstCol.id,
|
||||
title: card.title || "Untitled",
|
||||
description: card.description || null,
|
||||
priority: card.priority || null,
|
||||
due_date: card.due_date || null,
|
||||
position: pos++,
|
||||
created_by: data.user?.id ?? null,
|
||||
});
|
||||
}
|
||||
toasts.success(`Imported ${json.length} cards`);
|
||||
} else if (json.columns && Array.isArray(json.columns)) {
|
||||
// Full board format with columns
|
||||
let colPos = kanbanBoard.columns.length;
|
||||
for (const col of json.columns) {
|
||||
// Check if column already exists by name
|
||||
let targetCol = kanbanBoard.columns.find(
|
||||
(c) =>
|
||||
c.name.toLowerCase() ===
|
||||
(col.name || "").toLowerCase(),
|
||||
);
|
||||
|
||||
if (!targetCol) {
|
||||
const { data: newCol, error: colErr } = await supabase
|
||||
.from("kanban_columns")
|
||||
.insert({
|
||||
board_id: kanbanBoard.id,
|
||||
name: col.name || `Column ${colPos}`,
|
||||
position: colPos++,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (colErr || !newCol) continue;
|
||||
targetCol = { ...newCol, cards: [] };
|
||||
}
|
||||
|
||||
if (col.cards && Array.isArray(col.cards)) {
|
||||
let cardPos = targetCol.cards?.length ?? 0;
|
||||
for (const card of col.cards) {
|
||||
await supabase.from("kanban_cards").insert({
|
||||
column_id: targetCol.id,
|
||||
title: card.title || "Untitled",
|
||||
description: card.description || null,
|
||||
priority: card.priority || null,
|
||||
due_date: card.due_date || null,
|
||||
color: card.color || null,
|
||||
position: cardPos++,
|
||||
created_by: data.user?.id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalCards = json.columns.reduce(
|
||||
(sum: number, c: any) => sum + (c.cards?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
toasts.success(
|
||||
`Imported ${json.columns.length} columns, ${totalCards} cards`,
|
||||
);
|
||||
} else {
|
||||
toasts.error(
|
||||
"Unrecognized JSON format. Expected { columns: [...] } or [{ title, ... }]",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("JSON import failed", { error: err });
|
||||
toasts.error("Failed to import JSON — check file format");
|
||||
} finally {
|
||||
isImporting = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportJson() {
|
||||
if (!kanbanBoard) return;
|
||||
const exportData = {
|
||||
board: kanbanBoard.name,
|
||||
columns: kanbanBoard.columns.map((col) => ({
|
||||
name: col.name,
|
||||
cards: col.cards.map((card) => ({
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
priority: card.priority,
|
||||
due_date: card.due_date,
|
||||
color: card.color,
|
||||
assignee_id: card.assignee_id,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${kanbanBoard.name || "board"}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toasts.success("Board exported as JSON");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.document.name} - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
||||
{#if data.isKanban}
|
||||
<!-- Kanban: needs its own header since DocumentViewer is for documents -->
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={handleJsonImport}
|
||||
/>
|
||||
<header class="flex items-center gap-2 p-1">
|
||||
<h1 class="flex-1 font-heading text-h1 text-white truncate">
|
||||
{data.document.name}
|
||||
</h1>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon="upload"
|
||||
onclick={triggerImport}
|
||||
loading={isImporting}
|
||||
>
|
||||
Import JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon="download"
|
||||
onclick={handleExportJson}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<div class="h-full">
|
||||
{#if kanbanBoard}
|
||||
<KanbanBoard
|
||||
columns={kanbanBoard.columns}
|
||||
onCardClick={handleCardClick}
|
||||
onCardMove={handleCardMove}
|
||||
onAddCard={handleAddCard}
|
||||
onAddColumn={() => (showAddColumnModal = true)}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
canEdit={true}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30 animate-spin mb-4"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
<p class="text-light/50">Loading board...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Document Editor: use shared DocumentViewer component -->
|
||||
<DocumentViewer
|
||||
document={data.document}
|
||||
onSave={handleSave}
|
||||
mode="edit"
|
||||
locked={lockInfo.isLocked && !lockInfo.isOwnLock}
|
||||
lockedByName={lockInfo.lockedByName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Status Bar -->
|
||||
{#if isSaving}
|
||||
<div class="text-body-sm text-light/50">Saving...</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Kanban Card Detail Modal -->
|
||||
{#if showCardModal}
|
||||
<CardDetailModal
|
||||
isOpen={showCardModal}
|
||||
card={selectedCard}
|
||||
mode={cardModalMode}
|
||||
onClose={() => {
|
||||
showCardModal = false;
|
||||
selectedCard = null;
|
||||
targetColumnId = null;
|
||||
}}
|
||||
onUpdate={(updatedCard) => {
|
||||
if (kanbanBoard) {
|
||||
kanbanBoard = {
|
||||
...kanbanBoard,
|
||||
columns: kanbanBoard.columns.map((col) => ({
|
||||
...col,
|
||||
cards: col.cards.map((c) =>
|
||||
c.id === updatedCard.id ? updatedCard : c,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}}
|
||||
onDelete={(cardId) => handleDeleteCard(cardId)}
|
||||
columnId={targetColumnId ?? undefined}
|
||||
userId={data.user?.id}
|
||||
orgId={data.org.id}
|
||||
onCreate={(newCard) => {
|
||||
loadKanbanBoard();
|
||||
showCardModal = false;
|
||||
selectedCard = null;
|
||||
targetColumnId = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Add Column Modal -->
|
||||
<Modal
|
||||
isOpen={showAddColumnModal}
|
||||
onClose={() => {
|
||||
showAddColumnModal = false;
|
||||
newColumnName = "";
|
||||
}}
|
||||
title="Add Column"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Column Name"
|
||||
bind:value={newColumnName}
|
||||
placeholder="e.g., To Do, In Progress, Done"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showAddColumnModal = false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={handleAddColumn} disabled={!newColumnName.trim()}>
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal file
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.folder');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals, params }) => {
|
||||
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
|
||||
const { supabase } = locals;
|
||||
const { id } = params;
|
||||
|
||||
log.debug('Loading folder by ID', { data: { id, orgId: org.id } });
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (docError || !document) {
|
||||
log.error('Folder not found', { error: docError, data: { id, orgId: org.id } });
|
||||
throw error(404, 'Folder not found');
|
||||
}
|
||||
|
||||
if (document.type !== 'folder') {
|
||||
log.error('Document is not a folder', { data: { id, type: document.type } });
|
||||
throw error(404, 'Not a folder');
|
||||
}
|
||||
|
||||
// Load all documents in this org (for breadcrumb building and file listing)
|
||||
const { data: allDocuments } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.order('name');
|
||||
|
||||
return {
|
||||
folder: document,
|
||||
documents: allDocuments ?? [],
|
||||
user
|
||||
};
|
||||
};
|
||||
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal file
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { FileBrowser } from "$lib/components/documents";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
folder: Document;
|
||||
documents: Document[];
|
||||
user: { id: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let documents = $state(data.documents);
|
||||
$effect(() => {
|
||||
documents = data.documents;
|
||||
});
|
||||
const currentFolderId = $derived(data.folder.id);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.folder.name} - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full p-4 lg:p-5">
|
||||
<FileBrowser
|
||||
org={data.org}
|
||||
bind:documents
|
||||
{currentFolderId}
|
||||
user={data.user}
|
||||
/>
|
||||
</div>
|
||||
Reference in New Issue
Block a user