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

230 lines
8.8 KiB
Vue

<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { gql } from '@/lib/gql'
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>>({})
function buildTree(docs: DocMeta[]): TreeFolder {
const root: TreeFolder = { name: '', path: '', folders: [], docs: [] }
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
}
onMounted(async () => {
try {
const data = await gql<{ documents?: DocMeta[] }>(`{ documents { slug title } }`)
allDocs.value = data.documents ?? []
// expand all folders by default
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 = []
} finally {
loading.value = false
}
})
const flatItems = computed<FlatItem[]>(() => {
const items: FlatItem[] = []
const tree = buildTree(allDocs.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 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">
<!-- 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 -->
<button
v-if="item.type === 'folder'"
: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 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>
<!-- Document row -->
<button
v-else
: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.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">
<router-link
to="/doc/new"
class="btn-primary w-full text-center text-sm py-2"
@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="M12 4v16m8-8H4"/>
</svg>
New document
</router-link>
<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>
</div>
</template>