Files
Archivum/frontend/src/components/layout/Sidebar.vue

402 lines
15 KiB
Vue

<script setup lang="ts">
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { gql, createFolder, moveDocument } from '@/lib/gql'
import CreateItemDialog from './CreateItemDialog.vue'
const emit = defineEmits<{ close: [] }>()
interface DocMeta { slug: string; title: string }
interface TreeFolder {
name: string
path: string
folders: TreeFolder[]
docs: DocMeta[]
}
type FlatItem =
| { type: 'folder'; name: string; path: string; depth: number }
| { type: 'doc'; name: string; slug: string; depth: number }
const allDocs = ref<DocMeta[]>([])
const loading = ref(true)
const router = useRouter()
const route = useRoute()
const openFolders = reactive<Record<string, boolean>>({})
const allFolders = ref<string[]>([])
const draggedItem = ref<{ type: 'folder' | 'doc'; path: string } | null>(null)
const dropTarget = ref<string | null>(null)
const showCreateMenu = reactive<Record<string, boolean>>({})
watch(() => route.fullPath, () => {
for (const key in showCreateMenu) {
showCreateMenu[key] = false
}
})
function buildTree(docs: DocMeta[], folders: string[]): TreeFolder {
const root: TreeFolder = { name: '', path: '', folders: [], docs: [] }
for (const f of folders) {
const parts = f.split('/')
let node = root
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
let child = node.folders.find(c => c.path === folderPath)
if (!child) {
child = { name: parts[i], path: folderPath, folders: [], docs: [] }
node.folders.push(child)
}
node = child
}
}
for (const doc of docs) {
const parts = doc.slug.split('/')
if (parts.length === 1) {
root.docs.push(doc)
continue
}
let node = root
for (let i = 0; i < parts.length - 1; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
let child = node.folders.find(f => f.path === folderPath)
if (!child) {
child = { name: parts[i], path: folderPath, folders: [], docs: [] }
node.folders.push(child)
}
node = child
}
node.docs.push(doc)
}
return root
}
const createDialog = reactive({
isOpen: false,
type: 'document' as 'folder' | 'document',
parentPath: ''
})
function promptCreateRootFolder() {
createDialog.type = 'folder'
createDialog.parentPath = ''
createDialog.isOpen = true
}
function promptCreateItem(type: 'folder' | 'document', parentPath: string) {
createDialog.type = type
createDialog.parentPath = parentPath
createDialog.isOpen = true
}
async function handleDialogConfirm(name: string) {
const { type, parentPath } = createDialog
if (type === 'folder') {
try {
const newPath = parentPath ? `${parentPath}/${name}` : name
await createFolder(newPath)
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
allFolders.value = fData.folders ?? []
if (parentPath) openFolders[parentPath] = true
} catch (err) {
console.error('Failed to create folder:', err)
}
} else {
// Document
const slug = parentPath
? `${parentPath}/${name.toLowerCase().replace(/\\s+/g, '-')}`
: name.toLowerCase().replace(/\\s+/g, '-')
sessionStorage.setItem('newDocSlug', slug)
router.push('/doc/new')
emit('close')
}
}
function handleDragStart(e: DragEvent, item: FlatItem) {
if (item.type === 'folder') {
draggedItem.value = { type: 'folder', path: item.path }
} else {
draggedItem.value = { type: 'doc', path: item.slug }
}
e.dataTransfer!.effectAllowed = 'move'
}
function handleDragOver(e: DragEvent, targetPath: string) {
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
dropTarget.value = targetPath
}
function handleDragLeave() {
dropTarget.value = null
}
async function handleDrop(e: DragEvent, targetPath: string) {
e.preventDefault()
if (!draggedItem.value) return
try {
if (draggedItem.value.type === 'doc') {
const parts = draggedItem.value.path.split('/')
const docName = parts.pop()
const newSlug = targetPath ? `${targetPath}/${docName}` : docName!
await moveDocument(draggedItem.value.path, newSlug)
const data = await gql<{ documents?: DocMeta[] }>(`{ documents { slug title } }`)
allDocs.value = data.documents ?? []
}
} catch (err) {
console.error('Failed to move item:', err)
} finally {
draggedItem.value = null
dropTarget.value = null
}
}
onMounted(async () => {
try {
const data = await gql<{ documents?: DocMeta[] }>(`{ documents { slug title } }`)
allDocs.value = data.documents ?? []
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
allFolders.value = fData.folders ?? []
// expand all folders by default
for (const f of allFolders.value) {
openFolders[f] = true
}
for (const doc of allDocs.value) {
const parts = doc.slug.split('/')
for (let i = 1; i < parts.length; i++) {
openFolders[parts.slice(0, i).join('/')] = true
}
}
} catch {
allDocs.value = []
allFolders.value = []
} finally {
loading.value = false
}
})
const flatItems = computed<FlatItem[]>(() => {
const items: FlatItem[] = []
const tree = buildTree(allDocs.value, allFolders.value)
function traverse(node: TreeFolder, depth: number) {
for (const folder of node.folders) {
items.push({ type: 'folder', name: folder.name, path: folder.path, depth })
if (openFolders[folder.path]) {
traverse(folder, depth + 1)
}
}
for (const doc of node.docs) {
const label = doc.title || doc.slug.split('/').pop() || doc.slug
items.push({ type: 'doc', name: label, slug: doc.slug, depth })
}
}
traverse(tree, 0)
return items
})
function toggleFolder(path: string) {
openFolders[path] = !openFolders[path]
}
function closeAllMenus() {
for (const key in showCreateMenu) {
showCreateMenu[key] = false
}
}
function navigate(slug: string) {
router.push(`/doc/${slug}`)
emit('close')
}
function isActive(slug: string) {
const current = Array.isArray(route.params.slug)
? route.params.slug.join('/')
: (route.params.slug as string)
return current === slug
}
</script>
<template>
<div class="flex flex-col h-full select-none" @click="closeAllMenus">
<!-- Logo / header -->
<div class="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-slate-700/60">
<router-link
to="/"
class="flex items-center gap-2 font-bold text-slate-900 dark:text-white text-lg"
@click="emit('close')"
>
<!-- Book icon -->
<svg class="w-6 h-6 text-accent-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
Archivum
</router-link>
<button
class="btn-ghost p-1.5 lg:hidden"
@click="emit('close')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Search placeholder -->
<div class="px-3 py-3 border-b border-slate-200 dark:border-slate-700/60">
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-400 text-sm cursor-not-allowed">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<span>Search</span>
<kbd class="ml-auto text-xs bg-slate-200 dark:bg-slate-700 rounded px-1.5 py-0.5">K</kbd>
</div>
</div>
<!-- Document tree -->
<nav class="flex-1 overflow-y-auto py-2 px-2">
<div v-if="loading" class="space-y-1 px-2 mt-1">
<div v-for="i in 4" :key="i" class="h-7 rounded-md bg-slate-200 dark:bg-slate-700 animate-pulse" :style="{ width: `${60 + i * 8}%` }" />
</div>
<p v-else-if="flatItems.length === 0" class="text-xs text-slate-400 px-3 py-2">
No documents yet.
</p>
<template v-else>
<template v-for="item in flatItems" :key="item.type === 'folder' ? 'f:' + item.path : 'd:' + item.slug">
<!-- Folder row -->
<div
v-if="item.type === 'folder'"
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
class="flex items-center gap-1 group relative transition-colors duration-200"
:class="dropTarget === item.path ? 'bg-accent-500/20 dark:bg-accent-500/30 ring-1 ring-accent-500' : ''"
@dragover="handleDragOver($event, item.path)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, item.path)"
draggable="true"
@dragstart="handleDragStart($event, item)"
>
<button
class="flex-1 flex items-center gap-1.5 pr-2 py-1.5 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/60 transition"
@click="toggleFolder(item.path)"
>
<!-- chevron -->
<svg
class="w-3.5 h-3.5 flex-shrink-0 transition-transform"
:class="openFolders[item.path] ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<!-- folder icon -->
<svg class="w-4 h-4 flex-shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/>
</svg>
<span class="truncate font-medium">{{ item.name }}</span>
</button>
<button
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 mr-1 rounded hover:bg-slate-200 dark:hover:bg-slate-600"
@click.prevent.stop="showCreateMenu[item.path] = !showCreateMenu[item.path]"
title="Add into folder"
>
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</button>
<div
v-if="showCreateMenu[item.path]"
class="absolute right-0 top-full mt-1 w-32 bg-white dark:bg-slate-800 rounded-lg shadow-lg z-50 border border-slate-200 dark:border-slate-700"
>
<button
@click.stop="promptCreateItem('folder', item.path); showCreateMenu[item.path] = false"
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-t-lg flex items-center gap-2"
>
<span class="text-amber-500">📁</span> Mapp
</button>
<button
@click.stop="promptCreateItem('document', item.path); showCreateMenu[item.path] = false"
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-b-lg flex items-center gap-2"
>
<span class="text-indigo-400">📄</span> Dokument
</button>
</div>
</div>
<!-- Document row -->
<button
v-else
draggable="true"
@dragstart="handleDragStart($event, item)"
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
:class="[
'w-full flex items-center gap-1.5 pr-2 py-1.5 rounded-lg text-sm truncate transition',
isActive((item as any).slug)
? 'bg-accent-500/10 text-accent-600 dark:text-accent-400 font-medium'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/60'
]"
@click="navigate(item.slug)"
>
<!-- doc icon -->
<svg class="w-4 h-4 flex-shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 9.414V19a2 2 0 01-2 2z"/>
</svg>
<span class="truncate">{{ item.name }}</span>
</button>
</template>
</template>
</nav>
<!-- Bottom actions -->
<div class="p-3 border-t border-slate-200 dark:border-slate-700/60 flex flex-col gap-2">
<button
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-2"
@click="promptCreateRootFolder"
>
<span class="text-amber-500">📁</span>
Ny mapp (rot)
</button>
<button
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-2"
@click="promptCreateItem('document', '')"
>
<span class="text-indigo-200">📝</span>
Nytt dokument (rot)
</button>
<router-link
to="/admin"
class="w-full btn-ghost text-center text-sm py-1.5"
@click="emit('close')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Admin
</router-link>
</div>
<!-- Create Dialog (Folder / Document) -->
<CreateItemDialog
:is-open="createDialog.isOpen"
:type="createDialog.type"
@close="createDialog.isOpen = false"
@confirm="handleDialogConfirm"
/>
</div>
</template>