Implement AsciiDoc and TipTap conversion logic, including new Admonition node and CodeBlock extension; add DiffViewer and HistoryPanel components for document version comparison; introduce password hashing utility with SHA-256.

This commit is contained in:
2026-04-12 22:28:15 +02:00
parent 05b773c14c
commit 376b946e73
22 changed files with 2767 additions and 323 deletions

View File

@@ -1,31 +1,106 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
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 }
const docs = ref<DocMeta[]>([])
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 } }`)
docs.value = data.documents ?? []
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 {
docs.value = []
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>
@@ -72,23 +147,56 @@ function navigate(slug: string) {
<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="docs.length === 0" class="text-xs text-slate-400 px-3 py-2">
<p v-else-if="flatItems.length === 0" class="text-xs text-slate-400 px-3 py-2">
No documents yet.
</p>
<button
v-for="doc in docs"
:key="doc.slug"
:class="[
'w-full text-left px-3 py-2 rounded-lg text-sm truncate transition',
route.params.slug === doc.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(doc.slug)"
>
{{ doc.title || doc.slug }}
</button>
<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 -->