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:
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user