You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
333 lines
7.8 KiB
333 lines
7.8 KiB
<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 type { Document } from "$lib/supabase/types"; |
|
import type { SupabaseClient } from "@supabase/supabase-js"; |
|
import type { Database } from "$lib/supabase/types"; |
|
|
|
interface Props { |
|
data: { |
|
org: { id: string; name: string; slug: string }; |
|
documents: Document[]; |
|
user: { id: string } | null; |
|
}; |
|
} |
|
|
|
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 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; |
|
} |
|
} |
|
} |
|
</script> |
|
|
|
<svelte:head> |
|
<title |
|
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {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} |
|
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> |
|
|
|
<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>
|
|
|