230 lines
8.8 KiB
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>
|