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.
253 lines
6.2 KiB
253 lines
6.2 KiB
<script lang="ts"> |
|
import type { DocumentWithChildren } from "$lib/api/documents"; |
|
|
|
interface Props { |
|
items: DocumentWithChildren[]; |
|
selectedId?: string | null; |
|
onSelect: (doc: DocumentWithChildren) => void; |
|
onDoubleClick?: (doc: DocumentWithChildren) => void; |
|
onAdd?: (parentId: string | null) => void; |
|
onMove?: (docId: string, newParentId: string | null) => void; |
|
onEdit?: (doc: DocumentWithChildren) => void; |
|
onDelete?: (doc: DocumentWithChildren) => void; |
|
level?: number; |
|
} |
|
|
|
let { |
|
items, |
|
selectedId = null, |
|
onSelect, |
|
onDoubleClick, |
|
onAdd, |
|
onMove, |
|
onEdit, |
|
onDelete, |
|
level = 0, |
|
}: Props = $props(); |
|
|
|
let expandedFolders = $state<Set<string>>(new Set()); |
|
let dragOverId = $state<string | null>(null); |
|
|
|
function toggleFolder(id: string, e?: MouseEvent) { |
|
e?.stopPropagation(); |
|
const newSet = new Set(expandedFolders); |
|
if (newSet.has(id)) { |
|
newSet.delete(id); |
|
} else { |
|
newSet.add(id); |
|
} |
|
expandedFolders = newSet; |
|
} |
|
|
|
function handleSelect(doc: DocumentWithChildren) { |
|
onSelect(doc); |
|
} |
|
|
|
function handleAdd(e: MouseEvent, parentId: string | null) { |
|
e.stopPropagation(); |
|
onAdd?.(parentId); |
|
} |
|
|
|
function handleDragStart(e: DragEvent, doc: DocumentWithChildren) { |
|
if (!e.dataTransfer) return; |
|
e.dataTransfer.effectAllowed = "move"; |
|
e.dataTransfer.setData("text/plain", doc.id); |
|
} |
|
|
|
function handleDragOver( |
|
e: DragEvent, |
|
targetId: string | null, |
|
isFolder: boolean, |
|
) { |
|
if (!isFolder && targetId !== null) return; |
|
e.preventDefault(); |
|
dragOverId = targetId; |
|
} |
|
|
|
function handleDragLeave() { |
|
dragOverId = null; |
|
} |
|
|
|
function handleDrop(e: DragEvent, targetFolderId: string | null) { |
|
e.preventDefault(); |
|
dragOverId = null; |
|
const docId = e.dataTransfer?.getData("text/plain"); |
|
if (docId && docId !== targetFolderId) { |
|
onMove?.(docId, targetFolderId); |
|
} |
|
} |
|
</script> |
|
|
|
<div |
|
class="space-y-0.5" |
|
ondragover={(e) => level === 0 && handleDragOver(e, null, true)} |
|
ondragleave={handleDragLeave} |
|
ondrop={(e) => level === 0 && handleDrop(e, null)} |
|
role="tree" |
|
> |
|
{#each items as item} |
|
<div role="treeitem"> |
|
<div |
|
class="group w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors cursor-pointer |
|
{selectedId === item.id |
|
? 'bg-primary/20 text-primary' |
|
: 'text-light/80 hover:bg-light/5'} |
|
{dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}" |
|
onclick={() => handleSelect(item)} |
|
ondblclick={() => onDoubleClick?.(item)} |
|
draggable="true" |
|
ondragstart={(e) => handleDragStart(e, item)} |
|
ondragover={(e) => |
|
handleDragOver(e, item.id, item.type === "folder")} |
|
ondragleave={handleDragLeave} |
|
ondrop={(e) => item.type === "folder" && handleDrop(e, item.id)} |
|
role="button" |
|
tabindex="0" |
|
> |
|
{#if item.type === "folder"} |
|
<button |
|
class="p-0.5 hover:bg-light/10 rounded" |
|
onclick={(e) => toggleFolder(item.id, e)} |
|
aria-label="Toggle folder" |
|
> |
|
<svg |
|
class="w-4 h-4 transition-transform {expandedFolders.has( |
|
item.id, |
|
) |
|
? 'rotate-90' |
|
: ''}" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
> |
|
<path d="m9 18 6-6-6-6" /> |
|
</svg> |
|
</button> |
|
<svg |
|
class="w-4 h-4 text-warning" |
|
viewBox="0 0 24 24" |
|
fill="currentColor" |
|
> |
|
<path |
|
d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H12L10 5H5C3.89543 5 3 5.89543 3 7Z" |
|
/> |
|
</svg> |
|
{:else} |
|
<div class="w-5"></div> |
|
<svg |
|
class="w-4 h-4 text-light/50" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
> |
|
<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" /> |
|
<line x1="16" y1="13" x2="8" y2="13" /> |
|
<line x1="16" y1="17" x2="8" y2="17" /> |
|
</svg> |
|
{/if} |
|
<span class="flex-1 truncate text-sm">{item.name}</span> |
|
|
|
<div |
|
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity" |
|
> |
|
{#if item.type === "folder" && onAdd} |
|
<button |
|
class="p-1 hover:bg-light/10 rounded" |
|
onclick={(e) => handleAdd(e, item.id)} |
|
aria-label="Add to folder" |
|
> |
|
<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> |
|
{/if} |
|
{#if onEdit} |
|
<button |
|
class="p-1 hover:bg-light/10 rounded" |
|
onclick={(e) => { |
|
e.stopPropagation(); |
|
onEdit(item); |
|
}} |
|
aria-label="Rename" |
|
> |
|
<svg |
|
class="w-4 h-4" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
> |
|
<path |
|
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" |
|
/> |
|
<path |
|
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" |
|
/> |
|
</svg> |
|
</button> |
|
{/if} |
|
{#if onDelete} |
|
<button |
|
class="p-1 hover:bg-error/20 hover:text-error rounded" |
|
onclick={(e) => { |
|
e.stopPropagation(); |
|
onDelete(item); |
|
}} |
|
aria-label="Delete" |
|
> |
|
<svg |
|
class="w-4 h-4" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
> |
|
<polyline points="3,6 5,6 21,6" /> |
|
<path |
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" |
|
/> |
|
</svg> |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if item.type === "folder" && expandedFolders.has(item.id)} |
|
<div class="ml-4 border-l border-light/10 pl-2"> |
|
{#if item.children?.length} |
|
<svelte:self |
|
items={item.children} |
|
{selectedId} |
|
{onSelect} |
|
{onAdd} |
|
{onMove} |
|
{onEdit} |
|
{onDelete} |
|
level={level + 1} |
|
/> |
|
{:else} |
|
<p class="text-light/30 text-xs px-3 py-2 italic"> |
|
Empty folder |
|
</p> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
|
|
{#if items.length === 0 && level === 0} |
|
<p class="text-light/40 text-sm px-3 py-2">No documents yet</p> |
|
{/if} |
|
</div>
|
|
|