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

@@ -9,6 +9,22 @@
"version": "0.1.0",
"dependencies": {
"@tiptap/core": "^2.4.0",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/html": "^2.27.2",
"@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",
@@ -3068,6 +3084,23 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
@@ -3146,6 +3179,23 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/html": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.27.2.tgz",
"integrity": "sha512-WiZgAvFjUprjyAczxAHLN9k++w7klwsCJ7EJDljtW/QXQ476Q0GqNtaHG4WRuW25cBH7c7AWZFptALeak2OE4Q==",
"license": "MIT",
"dependencies": {
"zeed-dom": "^0.15.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
@@ -4155,6 +4205,18 @@
"node": ">=8"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -4748,7 +4810,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -5775,6 +5836,12 @@
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -8863,6 +8930,35 @@
"engines": {
"node": ">=12"
}
},
"node_modules/zeed-dom": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zeed-dom/-/zeed-dom-0.15.1.tgz",
"integrity": "sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==",
"license": "MIT",
"dependencies": {
"css-what": "^6.1.0",
"entities": "^5.0.0"
},
"engines": {
"node": ">=14.13.1"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/holtwick"
}
},
"node_modules/zeed-dom/node_modules/entities": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz",
"integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
}
}
}

View File

@@ -10,6 +10,22 @@
},
"dependencies": {
"@tiptap/core": "^2.4.0",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/html": "^2.27.2",
"@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",

View File

@@ -1,22 +1,18 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useThemeStore } from '@/stores/theme'
import AppLayout from '@/components/layout/AppLayout.vue'
import LoginView from '@/views/LoginView.vue'
import SetupWizard from '@/components/wizard/SetupWizard.vue'
const app = useAppStore()
const theme = useThemeStore()
const router = useRouter()
const ready = ref(false)
onMounted(async () => {
theme.init()
await app.checkStatus()
if (app.requiresSetup) {
router.replace('/setup')
}
ready.value = true
})
</script>
@@ -37,7 +33,7 @@ onMounted(async () => {
</div>
<template v-else>
<router-view v-if="app.requiresSetup" />
<SetupWizard v-if="app.requiresSetup" />
<LoginView v-else-if="!app.token" />
<AppLayout v-else />
</template>

View File

@@ -1,164 +1,179 @@
/**
* AsciiDoc ↔ TipTap Bridge
*
* Converts between raw AsciiDoc strings and TipTap/ProseMirror JSON documents.
* Uses Asciidoctor.js to parse AsciiDoc into an AST, then maps nodes to
* ProseMirror node types understood by TipTap's StarterKit.
*
* Round-trip guarantee: fromTipTap(toTipTap(adoc)) should be semantically
* equivalent to the original adoc (formatting may differ slightly).
*/
import Asciidoctor from 'asciidoctor'
import { generateJSON } from '@tiptap/html'
import type { JSONContent } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import { Admonition, CustomCodeBlock } from './asciidoc-extensions'
const asciidoctor = Asciidoctor()
// ── AsciiDoc → TipTap ────────────────────────────────────────────────────────
export function toTipTap(adoc: string): JSONContent {
if (!adoc.trim()) {
if (!adoc || !adoc.trim()) {
return { type: 'doc', content: [{ type: 'paragraph' }] }
}
const doc = asciidoctor.load(adoc, { safe: 'safe' })
const blocks = (doc.getBlocks?.() ?? []) as AsciidoctorBlock[]
const content = blocks.flatMap(convertBlock).filter(Boolean) as JSONContent[]
// Convert AsciiDoc to HTML, showtitle: false to skip huge document header wrappers
let htmlContent = asciidoctor.convert(adoc, {
attributes: { showtitle: false },
standalone: false
}) as string
return {
type: 'doc',
content: content.length ? content : [{ type: 'paragraph' }],
}
// Rewrite admonition tables to our custom tag before letting TipTap parse it
htmlContent = htmlContent.replace(
/<div class="admonitionblock ([^"]+)">[\s\S]*?<td class="content">\s*([\s\S]*?)\s*<\/td>[\s\S]*?<\/div>/g,
(_match, type, content) => {
// The content might contain paragraphs from asciidoctor.
return `<div data-type="admonition" data-admonition-type="${type}">${content}</div>`
}
)
const extensions = [
StarterKit.configure({ codeBlock: false }),
CustomCodeBlock,
Link,
Admonition
]
return generateJSON(htmlContent, extensions)
}
// ── TipTap → AsciiDoc ────────────────────────────────────────────────────────
export function fromTipTap(json: JSONContent): string {
if (!json.content?.length) return ''
return json.content.map(nodeToAdoc).join('\n\n')
}
class TipTapToAsciidoc {
json: JSONContent
// ── Internal converters ──────────────────────────────────────────────────────
// Asciidoctor.js types are not fully typed — use a minimal interface.
interface AsciidoctorBlock {
getNodeName(): string
getLevel?(): number
getTitle?(): string
getSource?(): string
getSourceLanguage?(): string
getContent?(): string
getBlocks?(): AsciidoctorBlock[]
getItems?(): AsciidoctorBlock[]
}
function convertBlock(block: AsciidoctorBlock): JSONContent | JSONContent[] {
const name = block.getNodeName()
switch (name) {
case 'section':
case 'preamble':
return (block.getBlocks?.() ?? []).flatMap(convertBlock)
case 'paragraph':
return {
type: 'paragraph',
content: parseInline(block.getContent?.() ?? ''),
}
case 'listing':
case 'literal': {
const lang = block.getSourceLanguage?.() ?? ''
return {
type: 'codeBlock',
attrs: { language: lang || null },
content: [{ type: 'text', text: block.getSource?.() ?? '' }],
}
}
case 'ulist':
return {
type: 'bulletList',
content: (block.getItems?.() ?? []).map((item) => ({
type: 'listItem',
content: [{ type: 'paragraph', content: parseInline(item.getContent?.() ?? '') }],
})),
}
case 'olist':
return {
type: 'orderedList',
content: (block.getItems?.() ?? []).map((item) => ({
type: 'listItem',
content: [{ type: 'paragraph', content: parseInline(item.getContent?.() ?? '') }],
})),
}
default:
// Fallback: render as paragraph.
return {
type: 'paragraph',
content: parseInline(block.getContent?.() ?? block.getSource?.() ?? ''),
}
constructor(tiptapJson: JSONContent) {
this.json = tiptapJson
}
}
/** Very basic inline markup → TipTap marks. */
function parseInline(text: string): JSONContent[] {
// Strip Asciidoctor HTML output to plain text for now.
// A full implementation would parse *bold*, _italic_, `code` etc.
const plain = text.replace(/<[^>]+>/g, '')
return plain ? [{ type: 'text', text: plain }] : []
}
function nodeToAdoc(node: JSONContent): string {
switch (node.type) {
case 'paragraph':
return inlineToAdoc(node.content ?? [])
case 'heading': {
const level = (node.attrs?.level as number) ?? 1
const prefix = '='.repeat(level + 1)
return `${prefix} ${inlineToAdoc(node.content ?? [])}`
convert(): string {
if (!this.json || this.json.type !== 'doc' || !this.json.content) {
return ''
}
case 'codeBlock': {
const lang = (node.attrs?.language as string | null) ?? ''
const src = node.content?.[0]?.text ?? ''
return `[source${lang ? ',' + lang : ''}]\n----\n${src}\n----`
}
case 'bulletList':
return (node.content ?? [])
.map((li) => `* ${inlineToAdoc(li.content?.[0]?.content ?? [])}`)
.join('\n')
case 'orderedList':
return (node.content ?? [])
.map((li) => `. ${inlineToAdoc(li.content?.[0]?.content ?? [])}`)
.join('\n')
case 'blockquote':
return `[quote]\n____\n${(node.content ?? []).map(nodeToAdoc).join('\n')}\n____`
case 'horizontalRule':
return "'''"
default:
return inlineToAdoc(node.content ?? [])
return this.processNodes(this.json.content).trim()
}
}
function inlineToAdoc(nodes: JSONContent[]): string {
return nodes
.map((n) => {
const text = n.text ?? ''
const marks = (n.marks ?? []).map((m) => m.type)
let result = text
if (marks.includes('bold')) result = `*${result}*`
if (marks.includes('italic')) result = `_${result}_`
if (marks.includes('code')) result = `\`${result}\``
return result
processNodes(nodes: JSONContent[], listLevel: number = 1, listType: 'bullet' | 'ordered' = 'bullet'): string {
let output = ''
nodes.forEach((node) => {
switch (node.type) {
case 'heading':
output += this.convertHeading(node)
break
case 'paragraph':
output += this.convertParagraph(node)
break
case 'bulletList':
output += this.processNodes(node.content || [], listLevel, 'bullet')
output += '\n' // Extra linebreak after list
break
case 'orderedList':
output += this.processNodes(node.content || [], listLevel, 'ordered')
output += '\n'
break
case 'listItem':
output += this.convertListItem(node, listLevel, listType)
break
case 'codeBlock':
output += this.convertCodeBlock(node)
break
case 'blockquote':
output += this.convertBlockquote(node)
break
case 'admonition':
output += this.convertAdmonition(node)
break
case 'horizontalRule':
output += "'''\n\n"
break
default:
console.warn(`Ohanterad nod-typ: ${node.type}`)
}
})
.join('')
return output
}
convertHeading(node: JSONContent): string {
const level = (node.attrs?.level as number) || 1
const prefix = '='.repeat(level)
const text = this.renderTextNodes(node.content || [])
return `${prefix} ${text}\n\n`
}
convertParagraph(node: JSONContent): string {
if (!node.content || node.content.length === 0) return '{blank}\n\n'
const text = this.renderTextNodes(node.content)
return `${text}\n\n`
}
convertListItem(node: JSONContent, level: number, type: 'bullet' | 'ordered'): string {
const prefix = type === 'ordered' ? '.'.repeat(level) : '*'.repeat(level)
let itemText = node.content
? this.processNodes(node.content, level + 1, type).trim()
: ''
return `${prefix} ${itemText}\n`
}
convertCodeBlock(node: JSONContent): string {
const lang = (node.attrs?.language as string) || ''
const code = this.renderTextNodes(node.content || [])
return `[source${lang ? ',' + lang : ''}]\n----\n${code}\n----\n\n`
}
convertBlockquote(node: JSONContent): string {
const text = node.content ? this.processNodes(node.content).trim() : ''
return `____\n${text}\n____\n\n`
}
convertAdmonition(node: JSONContent): string {
const type = (node.attrs?.type as string) || 'TIP'
const text = node.content ? this.processNodes(node.content).trim() : ''
return `[${type.toUpperCase()}]\n====\n${text}\n====\n\n`
}
renderTextNodes(nodes: JSONContent[]): string {
if (!nodes || nodes.length === 0) return ''
let textOut = ''
nodes.forEach((t) => {
if (t.type === 'text') {
let text = t.text || ''
if (t.marks) {
t.marks.forEach(mark => {
switch (mark.type) {
case 'bold': text = `*${text}*`; break
case 'italic': text = `_${text}_`; break
case 'strike': text = `[line-through]#${text}#`; break
case 'code': text = `\`${text}\``; break
case 'link':
const url = mark.attrs?.href || ''
if (url.startsWith('http://') || url.startsWith('https://')) {
text = `${url}[${text}]`
} else {
text = `link:${url}[${text}]`
}
break
}
})
}
textOut += text
}
if (t.type === 'hardBreak') {
textOut += ' +\n'
}
})
return textOut
}
}
export function fromTipTap(json: JSONContent): string {
const converter = new TipTapToAsciidoc(json)
return converter.convert()
}

View File

@@ -0,0 +1,41 @@
import { Node } from '@tiptap/core'
import CodeBlock from '@tiptap/extension-code-block'
export const Admonition = Node.create({
name: 'admonition',
group: 'block',
content: 'block+',
addAttributes() {
return {
type: { default: 'note' },
}
},
parseHTML() {
return [
{
tag: 'div[data-type="admonition"]',
getAttrs: (el) => {
if (typeof el === 'string') return {}
return { type: el.getAttribute('data-admonition-type') || 'note' }
},
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-type': 'admonition', 'data-admonition-type': HTMLAttributes.type, class: `admonition ${HTMLAttributes.type}` }, 0]
},
})
export const CustomCodeBlock = CodeBlock.extend({
addAttributes() {
return {
language: {
default: null,
parseHTML: (element) => {
const code = element.querySelector('code') || element
return code?.getAttribute('data-lang') || code?.className.match(/language-(\w+)/)?.[1] || null
},
},
}
},
})

View File

@@ -1,19 +1,34 @@
<script setup lang="ts">
import { watch, onBeforeUnmount } from 'vue'
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
import { Admonition, CustomCodeBlock } from '@/bridge/asciidoc-extensions'
const model = defineModel<string>({ required: true })
const isEditing = ref(false)
const editor = useEditor({
extensions: [StarterKit],
extensions: [
StarterKit.configure({ codeBlock: false }),
CustomCodeBlock,
Link,
Admonition
],
content: toTipTap(model.value),
editable: false,
onUpdate({ editor }) {
model.value = fromTipTap(editor.getJSON())
},
})
watch(isEditing, (editing) => {
editor.value?.setEditable(editing)
if (editing) nextTick(() => editor.value?.commands.focus())
})
// Sync external changes (e.g. switching from SourceEditor) into TipTap.
watch(model, (adoc) => {
if (!editor.value) return
@@ -25,10 +40,195 @@ watch(model, (adoc) => {
})
onBeforeUnmount(() => editor.value?.destroy())
function btnClass(active: boolean | undefined) {
return [
'px-2 py-0.5 rounded text-sm font-medium transition-colors select-none',
active ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-700',
]
}
</script>
<template>
<div class="p-4 prose prose-invert max-w-none">
<EditorContent :editor="editor" />
<div class="flex flex-col h-full bg-white dark:bg-slate-900 relative">
<!-- Toolbar -->
<div
v-if="editor"
class="flex overflow-x-auto items-center gap-1 p-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/80 flex-shrink-0"
:class="{ 'opacity-50 pointer-events-none': !isEditing }"
>
<button
@click="editor.chain().focus().toggleBold().run()"
:class="btnClass(editor.isActive('bold'))"
title="Fet [Ctrl+B]"
>
<span class="font-bold">B</span>
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="btnClass(editor.isActive('italic'))"
title="Kursiv [Ctrl+I]"
>
<span class="italic font-serif">I</span>
</button>
<button
@click="editor.chain().focus().toggleStrike().run()"
:class="btnClass(editor.isActive('strike'))"
title="Genomstruken"
>
<span class="line-through">S</span>
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<!-- Rubriker -->
<button
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="btnClass(editor.isActive('heading', { level: 1 }))"
>
H1
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="btnClass(editor.isActive('heading', { level: 2 }))"
>
H2
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="btnClass(editor.isActive('heading', { level: 3 }))"
>
H3
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<!-- Listor -->
<button
@click="editor.chain().focus().toggleBulletList().run()"
:class="btnClass(editor.isActive('bulletList'))"
>
Lista
</button>
<button
@click="editor.chain().focus().toggleOrderedList().run()"
:class="btnClass(editor.isActive('orderedList'))"
>
Numrerad
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<button
@click="editor.chain().focus().toggleBlockquote().run()"
:class="btnClass(editor.isActive('blockquote'))"
>
Citat
</button>
<button
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="btnClass(editor.isActive('codeBlock'))"
>
Kodblock
</button>
<div class="flex-grow"></div>
</div>
<!-- Edit Area -->
<div class="flex-grow overflow-y-auto p-4 w-full">
<div
class="max-w-2xl mx-auto prose prose-indigo dark:prose-invert"
@click="!isEditing && (isEditing = true)"
>
<EditorContent :editor="editor" />
</div>
</div>
</div>
</template>
<style>
/* ... Tiptyap core styles ... */
.ProseMirror {
outline: none !important;
min-height: 100%;
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
.ProseMirror pre {
background: #1f2937;
color: #f8fafc;
font-family: 'JetBrains Mono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
}
/* Admonition Styling for Editor rendering */
.ProseMirror div.admonition {
border-left: 4px solid #3b82f6;
background-color: #eff6ff;
padding: 1rem;
margin: 1rem 0;
border-radius: 0.25rem;
position: relative;
}
.ProseMirror div.admonition::before {
content: attr(data-admonition-type);
display: block;
font-weight: bold;
text-transform: uppercase;
color: #1e40af;
margin-bottom: 0.5rem;
}
.ProseMirror div.admonition.warning {
border-left-color: #eab308;
background-color: #fefce8;
}
.ProseMirror div.admonition.warning::before {
color: #a16207;
}
.ProseMirror div.admonition.important {
border-left-color: #ef4444;
background-color: #fef2f2;
}
.ProseMirror div.admonition.important::before {
color: #b91c1c;
}
/* Admonition Dark Mode */
html.dark .ProseMirror div.admonition,
.dark .ProseMirror div.admonition {
background-color: rgba(59, 130, 246, 0.15);
border-left-color: #60a5fa;
color: #e2e8f0;
}
html.dark .ProseMirror div.admonition::before,
.dark .ProseMirror div.admonition::before {
color: #93c5fd;
}
html.dark .ProseMirror div.admonition.warning,
.dark .ProseMirror div.admonition.warning {
background-color: rgba(234, 179, 8, 0.15);
border-left-color: #facc15;
}
html.dark .ProseMirror div.admonition.warning::before,
.dark .ProseMirror div.admonition.warning::before {
color: #fde047;
}
html.dark .ProseMirror div.admonition.important,
.dark .ProseMirror div.admonition.important {
background-color: rgba(239, 68, 68, 0.15);
border-left-color: #f87171;
}
html.dark .ProseMirror div.admonition.important::before,
.dark .ProseMirror div.admonition.important::before {
color: #fca5a5;
}
</style>

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
/**
* DiffViewer — shows the difference between two versions of a document.
*
* Three view modes:
* • raw-diff — classic unified diff with green/red line coloring
* • rendered-diff — rendered AsciiDoc, block-level LCS diff with colored sections
* • side-by-side — both rendered versions in two panels, no highlighting
*/
import { computed, ref } from 'vue'
import Asciidoctor from 'asciidoctor'
const asciidoctor = Asciidoctor()
// ── Props ─────────────────────────────────────────────────────────────────────
const props = defineProps<{
/** Unified diff text from `diff(slug, oldHash, newHash)` */
unifiedDiff: string
/** Raw AsciiDoc of the older (from) version */
oldContent: string
/** Raw AsciiDoc of the newer (to) version */
newContent: string
/** Display label for the old version (e.g. short hash) */
oldLabel: string
/** Display label for the new version */
newLabel: string
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
// ── Mode ──────────────────────────────────────────────────────────────────────
type Mode = 'raw-diff' | 'rendered-diff' | 'side-by-side'
const mode = ref<Mode>('raw-diff')
// ── Raw diff parsing ──────────────────────────────────────────────────────────
interface DiffLine {
type: 'added' | 'removed' | 'unchanged' | 'hunk' | 'meta'
content: string
lineOld: number | null
lineNew: number | null
}
const parsedDiff = computed<DiffLine[]>(() => {
const lines = props.unifiedDiff.split('\n')
const result: DiffLine[] = []
let lineOld = 0
let lineNew = 0
for (const raw of lines) {
if (raw.startsWith('diff ') || raw.startsWith('index ') || raw.startsWith('--- ') || raw.startsWith('+++ ')) {
result.push({ type: 'meta', content: raw, lineOld: null, lineNew: null })
continue
}
if (raw.startsWith('@@')) {
const m = raw.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
if (m) {
lineOld = parseInt(m[1])
lineNew = parseInt(m[2])
}
result.push({ type: 'hunk', content: raw, lineOld: null, lineNew: null })
continue
}
if (raw.startsWith('+')) {
result.push({ type: 'added', content: raw.slice(1), lineOld: null, lineNew: lineNew++ })
} else if (raw.startsWith('-')) {
result.push({ type: 'removed', content: raw.slice(1), lineOld: lineOld++, lineNew: null })
} else {
const content = raw.startsWith(' ') ? raw.slice(1) : raw
result.push({ type: 'unchanged', content, lineOld: lineOld++, lineNew: lineNew++ })
}
}
return result
})
// ── Block-level LCS diff (for rendered-diff mode) ─────────────────────────────
/** Split AsciiDoc into logical blocks separated by blank lines. */
function splitBlocks(adoc: string): string[] {
return adoc
.split(/\n{2,}/)
.map((b) => b.trim())
.filter((b) => b.length > 0)
}
/** LCS matrix — returns the DP table. */
function buildLCS(a: string[], b: string[]): number[][] {
const m = a.length
const n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
return dp
}
type BlockStatus = 'unchanged' | 'removed' | 'added'
interface AnnotatedBlock {
status: BlockStatus
content: string
}
/** Compute side-specific annotated block lists using LCS backtracking. */
function computeBlockDiff(
oldBlocks: string[],
newBlocks: string[],
): { oldSide: AnnotatedBlock[]; newSide: AnnotatedBlock[] } {
const dp = buildLCS(oldBlocks, newBlocks)
const oldSide: AnnotatedBlock[] = []
const newSide: AnnotatedBlock[] = []
let i = oldBlocks.length
let j = newBlocks.length
const oldTemp: AnnotatedBlock[] = []
const newTemp: AnnotatedBlock[] = []
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldBlocks[i - 1] === newBlocks[j - 1]) {
oldTemp.push({ status: 'unchanged', content: oldBlocks[i - 1] })
newTemp.push({ status: 'unchanged', content: newBlocks[j - 1] })
i--
j--
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
newTemp.push({ status: 'added', content: newBlocks[j - 1] })
j--
} else {
oldTemp.push({ status: 'removed', content: oldBlocks[i - 1] })
i--
}
}
oldTemp.reverse().forEach((b) => oldSide.push(b))
newTemp.reverse().forEach((b) => newSide.push(b))
return { oldSide, newSide }
}
function renderBlock(adoc: string): string {
if (!adoc.trim()) return ''
return asciidoctor.convert(adoc, { safe: 'safe', standalone: false }) as string
}
const blockDiff = computed(() => {
const oldBlocks = splitBlocks(props.oldContent)
const newBlocks = splitBlocks(props.newContent)
return computeBlockDiff(oldBlocks, newBlocks)
})
// ── Rendered HTML for side-by-side mode ──────────────────────────────────────
const oldRendered = computed(() =>
(asciidoctor.convert(props.oldContent || '', { safe: 'safe', standalone: false }) as string) || '<p class="text-slate-400 italic">(empty)</p>',
)
const newRendered = computed(() =>
(asciidoctor.convert(props.newContent || '', { safe: 'safe', standalone: false }) as string) || '<p class="text-slate-400 italic">(empty)</p>',
)
</script>
<template>
<div class="flex flex-col h-full overflow-hidden bg-white dark:bg-slate-900">
<!-- Header bar -->
<div
class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-800/60 flex-shrink-0 flex-wrap"
>
<!-- Back button -->
<button
class="flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors"
@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="M15 19l-7-7 7-7" />
</svg>
Back to document
</button>
<div class="text-slate-300 dark:text-slate-600">|</div>
<!-- Version labels -->
<div class="flex items-center gap-2 text-xs">
<span class="px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 font-mono">
{{ oldLabel }}
</span>
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span class="px-2 py-0.5 rounded bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 font-mono">
{{ newLabel }}
</span>
</div>
<div class="flex-1" />
<!-- Mode switcher -->
<div class="flex items-center gap-1 rounded-lg bg-slate-200 dark:bg-slate-700/60 p-0.5">
<button
:class="[
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
mode === 'raw-diff'
? 'bg-white dark:bg-slate-600 shadow-sm text-slate-900 dark:text-slate-100'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200',
]"
@click="mode = 'raw-diff'"
>Raw diff</button>
<button
:class="[
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
mode === 'rendered-diff'
? 'bg-white dark:bg-slate-600 shadow-sm text-slate-900 dark:text-slate-100'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200',
]"
@click="mode = 'rendered-diff'"
>Rendered diff</button>
<button
:class="[
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
mode === 'side-by-side'
? 'bg-white dark:bg-slate-600 shadow-sm text-slate-900 dark:text-slate-100'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200',
]"
@click="mode = 'side-by-side'"
>Side by side</button>
</div>
</div>
<!-- Raw diff view -->
<div
v-if="mode === 'raw-diff'"
class="flex-1 overflow-auto font-mono text-xs leading-5"
>
<div v-if="!unifiedDiff.trim()" class="p-6 text-slate-400 italic">No differences found.</div>
<table v-else class="w-full border-collapse">
<tbody>
<tr v-for="(line, idx) in parsedDiff" :key="idx"
:class="{
'bg-green-50 dark:bg-green-900/20': line.type === 'added',
'bg-red-50 dark:bg-red-900/20': line.type === 'removed',
'bg-slate-100 dark:bg-slate-800/60 text-slate-400': line.type === 'hunk',
'text-slate-400 dark:text-slate-600': line.type === 'meta',
}"
>
<!-- Line number old -->
<td class="select-none w-10 text-right pr-2 pl-1 text-slate-400 dark:text-slate-600 border-r border-slate-200 dark:border-slate-700/40">
{{ line.lineOld ?? '' }}
</td>
<!-- Line number new -->
<td class="select-none w-10 text-right pr-2 pl-1 text-slate-400 dark:text-slate-600 border-r border-slate-200 dark:border-slate-700/40">
{{ line.lineNew ?? '' }}
</td>
<!-- Gutter marker -->
<td class="select-none w-5 text-center font-bold"
:class="{
'text-green-600 dark:text-green-400': line.type === 'added',
'text-red-500 dark:text-red-400': line.type === 'removed',
}"
>
<span v-if="line.type === 'added'">+</span>
<span v-else-if="line.type === 'removed'">-</span>
<span v-else-if="line.type === 'hunk'">@@</span>
</td>
<!-- Content -->
<td class="pl-2 pr-4 whitespace-pre-wrap break-all">
<span
:class="{
'text-green-800 dark:text-green-300': line.type === 'added',
'text-red-800 dark:text-red-300': line.type === 'removed',
'text-slate-500 dark:text-slate-400': line.type === 'hunk' || line.type === 'meta',
'text-slate-800 dark:text-slate-200': line.type === 'unchanged',
}"
>{{ line.content }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Rendered diff view -->
<div v-else-if="mode === 'rendered-diff'" class="flex-1 flex min-h-0 divide-x divide-slate-200 dark:divide-slate-700/60">
<!-- Old side -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<div class="px-4 py-2 text-xs font-semibold text-red-600 dark:text-red-400 bg-red-50/60 dark:bg-red-900/10 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
{{ oldLabel }} removed
</div>
<div class="flex-1 overflow-auto px-6 py-4 space-y-2">
<template v-for="(block, idx) in blockDiff.oldSide" :key="idx">
<div
v-if="block.status !== 'added'"
:class="[
'rounded px-3 py-2 prose dark:prose-invert prose-sm max-w-none',
block.status === 'removed'
? 'bg-red-50 dark:bg-red-900/20 ring-1 ring-red-300 dark:ring-red-700/50'
: '',
]"
v-html="renderBlock(block.content)"
/>
</template>
</div>
</div>
<!-- New side -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<div class="px-4 py-2 text-xs font-semibold text-green-600 dark:text-green-400 bg-green-50/60 dark:bg-green-900/10 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
{{ newLabel }} added
</div>
<div class="flex-1 overflow-auto px-6 py-4 space-y-2">
<template v-for="(block, idx) in blockDiff.newSide" :key="idx">
<div
v-if="block.status !== 'removed'"
:class="[
'rounded px-3 py-2 prose dark:prose-invert prose-sm max-w-none',
block.status === 'added'
? 'bg-green-50 dark:bg-green-900/20 ring-1 ring-green-300 dark:ring-green-700/50'
: '',
]"
v-html="renderBlock(block.content)"
/>
</template>
</div>
</div>
</div>
<!-- Side-by-side view -->
<div v-else class="flex-1 flex min-h-0 divide-x divide-slate-200 dark:divide-slate-700/60">
<!-- Old side -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<div class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-slate-300 bg-slate-100/60 dark:bg-slate-800/40 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
{{ oldLabel }}
</div>
<div
class="flex-1 overflow-auto px-6 py-4 prose dark:prose-invert prose-sm max-w-none"
v-html="oldRendered"
/>
</div>
<!-- New side -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<div class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-slate-300 bg-slate-100/60 dark:bg-slate-800/40 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
{{ newLabel }}
</div>
<div
class="flex-1 overflow-auto px-6 py-4 prose dark:prose-invert prose-sm max-w-none"
v-html="newRendered"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { gql } from '@/lib/gql'
export interface CommitEntry {
hash: string
author: string
email: string
date: string
subject: string
added: number
removed: number
}
const props = defineProps<{
slug: string
isOpen: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'view-version', entry: CommitEntry): void
(e: 'compare-with-latest', entry: CommitEntry, latestHash: string): void
}>()
const history = ref<CommitEntry[]>([])
const loading = ref(false)
const error = ref('')
async function loadHistory() {
if (!props.slug || props.slug === 'new') return
loading.value = true
error.value = ''
try {
const data = await gql<{ history: CommitEntry[] }>(
`query History($slug: String!) {
history(slug: $slug) {
hash author email date subject added removed
}
}`,
{ slug: props.slug },
)
history.value = data.history ?? []
} catch (e: any) {
error.value = e.message ?? 'Failed to load history'
} finally {
loading.value = false
}
}
watch(
() => [props.isOpen, props.slug] as const,
([open]) => {
if (open) loadHistory()
},
{ immediate: true },
)
function formatDate(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<template>
<transition name="history-slide">
<div
v-if="isOpen"
class="flex flex-col w-80 flex-shrink-0 border-l border-slate-200 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-800/60 overflow-hidden"
>
<!-- Panel header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700/60 flex-shrink-0">
<div class="flex items-center gap-2">
<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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 class="text-sm font-semibold text-slate-700 dark:text-slate-200">History</h2>
</div>
<button
class="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
aria-label="Close history panel"
@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>
<!-- Content -->
<div class="flex-1 overflow-y-auto">
<div v-if="loading" class="p-4 text-sm text-slate-400 animate-pulse">Loading history</div>
<div v-else-if="error" class="p-4 text-sm text-red-400">{{ error }}</div>
<div v-else-if="history.length === 0" class="p-4 text-sm text-slate-400">
No commits found for this document.
</div>
<ul v-else class="divide-y divide-slate-200 dark:divide-slate-700/40">
<li
v-for="(entry, idx) in history"
:key="entry.hash"
class="p-4 hover:bg-white/60 dark:hover:bg-slate-700/30 transition-colors"
>
<!-- Commit subject -->
<p class="text-sm font-medium text-slate-800 dark:text-slate-100 mb-1 leading-snug line-clamp-2">
{{ entry.subject || '(no message)' }}
</p>
<!-- Author + date -->
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2">
<span class="font-medium">{{ entry.author }}</span>
· {{ formatDate(entry.date) }}
</p>
<!-- Line diff stats -->
<div class="flex items-center gap-3 mb-3">
<span class="flex items-center gap-1 text-xs font-mono font-semibold text-green-600 dark:text-green-400">
<span>+{{ entry.added }}</span>
</span>
<span class="flex items-center gap-1 text-xs font-mono font-semibold text-red-500 dark:text-red-400">
<span>-{{ entry.removed }}</span>
</span>
<span class="text-xs text-slate-400 font-mono truncate flex-1" :title="entry.hash">
{{ entry.hash.slice(0, 7) }}
</span>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
class="text-xs px-2.5 py-1 rounded bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 transition-colors"
@click="emit('view-version', entry)"
>
View
</button>
<button
v-if="idx > 0"
class="text-xs px-2.5 py-1 rounded bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-800/60 text-blue-700 dark:text-blue-300 transition-colors"
@click="emit('compare-with-latest', entry, history[0].hash)"
>
Compare with latest
</button>
</div>
</li>
</ul>
</div>
</div>
</transition>
</template>
<style scoped>
.history-slide-enter-active,
.history-slide-leave-active {
transition: all 0.2s ease-in-out;
}
.history-slide-enter-from,
.history-slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -39,7 +39,7 @@ const sidebarOpen = ref(false)
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
<!-- Top bar (always visible on mobile, only shows breadcrumb on desktop) -->
<header class="flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-700/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md">
<header class="relative z-40 flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-700/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md">
<!-- Hamburger (mobile only) -->
<button
class="btn-ghost p-2 -ml-2 lg:hidden"

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 -->

View File

@@ -2,6 +2,7 @@
import { reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { gql } from '@/lib/gql'
import { hashPassword } from '@/lib/crypto'
import { useAppStore } from '@/stores/app'
import { useThemeStore } from '@/stores/theme'
import ThemeToggle from '@/components/layout/ThemeToggle.vue'
@@ -12,7 +13,7 @@ const app = useAppStore()
useThemeStore() // ensures theme is initialized before ThemeToggle renders
const step = ref(1)
const totalSteps = 3
const totalSteps = ref(3)
const error = ref('')
const submitting = ref(false)
@@ -26,6 +27,11 @@ const passwordMismatch = computed(
() => adminPassConfirm.value.length > 0 && adminPassConfirm.value !== form.adminPass
)
// Git initial commit state (step 4)
const gitCommitMessage = ref('Initial commit')
const committing = ref(false)
const gitError = ref('')
const form = reactive({
storagePath: '/data/wiki',
adminUser: 'admin',
@@ -50,7 +56,6 @@ const stepValid = computed(() => {
}
return true
})
async function testLdap() {
ldapTesting.value = true
ldapTestResult.value = null
@@ -77,13 +82,14 @@ async function submit() {
error.value = ''
submitting.value = true
try {
const hashedAdminPass = await hashPassword(form.adminUser, form.adminPass)
await gql(
`mutation Setup($i: SetupInput!) { setup(input: $i) }`,
{
i: {
storagePath: form.storagePath,
adminUser: form.adminUser,
adminPass: form.adminPass,
adminPass: hashedAdminPass,
jwtSecret: form.jwtSecret,
ldap: form.ldapEnabled ? form.ldap : null,
},
@@ -91,7 +97,17 @@ async function submit() {
)
app.requiresSetup = false
await app.login(form.adminUser, form.adminPass)
router.replace('/')
// Check if the storage path has uncommitted files.
const statusData = await gql<{ repoStatus: { hasUncommitted: boolean } }>(
`{ repoStatus { hasUncommitted } }`
)
if (statusData.repoStatus.hasUncommitted) {
totalSteps.value = 4
step.value = 4
} else {
router.replace('/')
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Setup failed'
step.value = 1
@@ -99,6 +115,22 @@ async function submit() {
submitting.value = false
}
}
async function doInitCommit() {
gitError.value = ''
committing.value = true
try {
await gql(
`mutation InitCommit($message: String!) { initCommit(message: $message) }`,
{ message: gitCommitMessage.value || 'Initial commit' },
)
router.replace('/')
} catch (e: unknown) {
gitError.value = e instanceof Error ? e.message : 'Commit failed'
} finally {
committing.value = false
}
}
</script>
<template>
@@ -261,7 +293,7 @@ async function submit() {
</template>
<!-- Step 3: Review -->
<template v-else>
<template v-else-if="step === 3">
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-1">Review & Finish</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm mb-6">Check your settings before completing setup.</p>
@@ -290,10 +322,48 @@ async function submit() {
</p>
</template>
<!-- Step 4: Git initial commit -->
<template v-else>
<div class="flex items-center gap-3 mb-4">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7C5 4 4 5 4 7z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6M9 8h6M9 16h4"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white">Initialize Git repository</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm">The storage folder has files that aren't tracked yet.</p>
</div>
</div>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-5">
Create an initial commit to start tracking all existing files in
<code class="font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded">{{ form.storagePath }}</code>
with Git.
</p>
<div>
<label class="label">Commit message</label>
<input
v-model="gitCommitMessage"
class="input"
placeholder="Initial commit"
/>
</div>
<p v-if="gitError" class="mt-4 flex items-center gap-2 text-sm text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg px-3 py-2.5">
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ gitError }}
</p>
</template>
<!-- Navigation buttons -->
<div class="flex items-center gap-3 mt-8">
<button
v-if="step > 1"
v-if="step > 1 && step < 4"
class="btn-secondary"
:disabled="submitting"
@click="step--"
@@ -301,26 +371,42 @@ async function submit() {
<div class="flex-1" />
<button
v-if="step < totalSteps"
class="btn-primary"
:disabled="!stepValid"
@click="step++"
>
Continue
</button>
<button
v-else
class="btn-primary min-w-32"
:disabled="submitting"
@click="submit"
>
<svg v-if="submitting" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
{{ submitting ? 'Setting up…' : 'Finish setup' }}
</button>
<template v-if="step === 4">
<button class="btn-secondary" @click="router.replace('/')">Skip</button>
<button
class="btn-primary min-w-36"
:disabled="committing || !gitCommitMessage.trim()"
@click="doInitCommit"
>
<svg v-if="committing" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
{{ committing ? 'Committing' : 'Commit & finish' }}
</button>
</template>
<template v-else-if="step < totalSteps">
<button
class="btn-primary"
:disabled="!stepValid"
@click="step++"
>
Continue →
</button>
</template>
<template v-else>
<button
class="btn-primary min-w-32"
:disabled="submitting"
@click="submit"
>
<svg v-if="submitting" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
{{ submitting ? 'Setting up' : 'Finish setup' }}
</button>
</template>
</div>
</div>

View File

@@ -0,0 +1,14 @@
/**
* Derives a deterministic hex digest from username + password using SHA-256.
* The username is used as a domain-separator so the same password produces
* a different hash for every account. The plaintext password never leaves
* the browser.
*/
export async function hashPassword(username: string, password: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(`${username.toLowerCase()}:${password}`)
const buffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { gql } from '@/lib/gql'
import { hashPassword } from '@/lib/crypto'
export const useAppStore = defineStore('app', () => {
const requiresSetup = ref(false)
@@ -17,13 +18,14 @@ export const useAppStore = defineStore('app', () => {
}
async function login(user: string, password: string) {
const hashed = await hashPassword(user, password)
const data = await gql<{ login: string }>(
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
{ u: user, p: password },
{ u: user, p: hashed },
)
token.value = data.login
username.value = user
localStorage.setItem('token', data.login); console.log('[login] token is:', data.login, localStorage.getItem('token'))
localStorage.setItem('token', data.login)
localStorage.setItem('username', user)
}

View File

@@ -17,6 +17,11 @@ const newPass = ref('')
const loading = ref(true)
const status = ref({ msg: '', isError: false })
// Git initial commit prompt state
const showGitPrompt = ref(false)
const gitCommitMessage = ref('Initial commit')
const gitCommitting = ref(false)
onMounted(async () => {
try {
const data = await gql<{ config: any }>(`{ config { storagePath ldap { host port baseDN bindDN } } }`)
@@ -44,12 +49,35 @@ function showStatus(msg: string, isError: boolean = false) {
async function saveStorage() {
try {
await gql(`mutation UpdateStorage($path: String!) { updateStoragePath(path: $path) }`, { path: storagePath.value })
showStatus('Storage path updated.')
// Check if the new storage path has uncommitted files.
const data = await gql<{ repoStatus: { hasUncommitted: boolean } }>(`{ repoStatus { hasUncommitted } }`)
if (data.repoStatus.hasUncommitted) {
showGitPrompt.value = true
gitCommitMessage.value = 'Initial commit'
} else {
showStatus('Storage path updated.')
}
} catch (err: any) {
showStatus(err.message, true)
}
}
async function doInitCommit() {
gitCommitting.value = true
try {
await gql(
`mutation InitCommit($message: String!) { initCommit(message: $message) }`,
{ message: gitCommitMessage.value || 'Initial commit' },
)
showGitPrompt.value = false
showStatus('Storage path updated and initial commit created.')
} catch (err: any) {
showStatus(err.message, true)
} finally {
gitCommitting.value = false
}
}
async function saveLdap() {
try {
let input = null
@@ -113,6 +141,50 @@ async function savePassword() {
</div>
</section>
<!-- Git initial commit prompt (shown after storage path change if repo has uncommitted files) -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<section v-if="showGitPrompt" class="card p-6 border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10">
<div class="flex items-start gap-3 mb-4">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<h3 class="font-semibold text-slate-900 dark:text-white">Untracked files found</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
The storage folder contains files that are not yet tracked by Git. Create an initial commit to start version-tracking them.
</p>
</div>
</div>
<div class="space-y-3">
<div>
<label class="label">Commit message</label>
<input v-model="gitCommitMessage" class="input" placeholder="Initial commit" />
</div>
<div class="flex gap-2 justify-end">
<button class="btn-secondary" @click="showGitPrompt = false; showStatus('Storage path updated.')">Skip</button>
<button
class="btn-primary"
:disabled="gitCommitting || !gitCommitMessage.trim()"
@click="doInitCommit"
>
<svg v-if="gitCommitting" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
{{ gitCommitting ? 'Committing…' : 'Commit files' }}
</button>
</div>
</div>
</section>
</transition>
<!-- Admin Account -->
<section class="card p-6">
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Admin Account</h2>

View File

@@ -1,13 +1,21 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { gql } from '@/lib/gql'
import VisualEditor from '@/components/editor/VisualEditor.vue'
import SourceEditor from '@/components/editor/SourceEditor.vue'
import HistoryPanel from '@/components/history/HistoryPanel.vue'
import DiffViewer from '@/components/history/DiffViewer.vue'
import type { CommitEntry } from '@/components/history/HistoryPanel.vue'
const route = useRoute()
const router = useRouter()
const slug = computed(() => route.params.slug as string)
const slug = computed(() => {
const s = route.params.slug
return Array.isArray(s) ? s.join('/') : (s as string)
})
// ── Normal editing state ──────────────────────────────────────────────────────
const docPath = ref('')
const content = ref('')
@@ -16,24 +24,36 @@ const loading = ref(true)
const saving = ref(false)
const commitMsg = ref('Update document')
onMounted(async () => {
if (slug.value === 'new') {
loading.value = false
async function loadDocument(s: string) {
// Leave any special mode when navigating to a different doc
historyPanelOpen.value = false
exitVersionView()
exitDiffView()
if (s === 'new') {
content.value = ''
docPath.value = ''
commitMsg.value = 'Initial commit'
loading.value = false
return
}
docPath.value = slug.value
loading.value = true
docPath.value = s
commitMsg.value = 'Update document'
try {
const data = await gql<{ document: { content: string } }>(
`query Doc($s: String!) { document(slug: $s) { content } }`,
{ s: slug.value },
{ s },
)
content.value = data.document?.content ?? ''
} finally {
loading.value = false
}
})
}
onMounted(() => loadDocument(slug.value))
watch(slug, (newSlug) => loadDocument(newSlug))
async function save() {
const targetSlug = slug.value === 'new' ? docPath.value : slug.value
@@ -41,7 +61,6 @@ async function save() {
alert('Please enter a document name.')
return
}
saving.value = true
try {
const res = await gql<{ saveDocument: { slug: string } }>(
@@ -55,47 +74,269 @@ async function save() {
saving.value = false
}
}
// ── History panel ─────────────────────────────────────────────────────────────
const historyPanelOpen = ref(false)
function toggleHistory() {
historyPanelOpen.value = !historyPanelOpen.value
}
// ── Historical version viewer ─────────────────────────────────────────────────
const viewingVersion = ref<CommitEntry | null>(null)
const versionContent = ref('')
const versionLoading = ref(false)
const versionEditorMode = ref<'visual' | 'source'>('visual')
async function onViewVersion(entry: CommitEntry) {
exitDiffView()
viewingVersion.value = entry
versionLoading.value = true
try {
const data = await gql<{ documentAtCommit: string }>(
`query DocAt($slug: String!, $hash: String!) { documentAtCommit(slug: $slug, hash: $hash) }`,
{ slug: slug.value, hash: entry.hash },
)
versionContent.value = data.documentAtCommit ?? ''
} finally {
versionLoading.value = false
}
}
function exitVersionView() {
viewingVersion.value = null
versionContent.value = ''
}
// ── Diff / compare view ───────────────────────────────────────────────────────
const diffActive = ref(false)
const diffOldContent = ref('')
const diffNewContent = ref('')
const diffUnified = ref('')
const diffOldLabel = ref('')
const diffNewLabel = ref('')
const diffLoading = ref(false)
/** Called from HistoryPanel: compare `entry` hash against the latest (history[0]) */
async function onCompareWithLatest(entry: CommitEntry, latestHash: string) {
exitVersionView()
diffLoading.value = true
try {
const [atOld, atNew, diffRes] = await Promise.all([
gql<{ documentAtCommit: string }>(
`query DocAt($slug: String!, $hash: String!) { documentAtCommit(slug: $slug, hash: $hash) }`,
{ slug: slug.value, hash: entry.hash },
),
gql<{ documentAtCommit: string }>(
`query DocAt($slug: String!, $hash: String!) { documentAtCommit(slug: $slug, hash: $hash) }`,
{ slug: slug.value, hash: latestHash },
),
gql<{ diff: string }>(
`query Diff($slug: String!, $fromHash: String!, $toHash: String!) { diff(slug: $slug, fromHash: $fromHash, toHash: $toHash) }`,
{ slug: slug.value, fromHash: entry.hash, toHash: latestHash },
),
])
diffOldContent.value = atOld.documentAtCommit ?? ''
diffNewContent.value = atNew.documentAtCommit ?? ''
diffUnified.value = diffRes.diff ?? ''
diffOldLabel.value = entry.hash.slice(0, 7)
diffNewLabel.value = latestHash.slice(0, 7) + ' (latest)'
diffActive.value = true
} finally {
diffLoading.value = false
}
}
/** Called from version viewer's own "Compare with latest" button */
async function compareVersionWithLatest() {
if (!viewingVersion.value) return
// We need the latest hash — re-fetch history to get it
const histData = await gql<{ history: CommitEntry[] }>(
`query History($slug: String!) { history(slug: $slug) { hash author email date subject added removed } }`,
{ slug: slug.value },
)
const entries = histData.history ?? []
if (entries.length === 0) return
await onCompareWithLatest(viewingVersion.value, entries[0].hash)
}
function exitDiffView() {
diffActive.value = false
diffOldContent.value = ''
diffNewContent.value = ''
diffUnified.value = ''
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- Toolbar -->
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-slate-100 dark:bg-slate-800 flex-wrap">
<button
:class="['px-3 py-1 rounded text-sm', editorMode === 'visual' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700']"
@click="editorMode = 'visual'"
>Visual</button>
<button
:class="['px-3 py-1 rounded text-sm', editorMode === 'source' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700']"
@click="editorMode = 'source'"
>Source</button>
<div class="flex h-full overflow-hidden">
<div class="flex-1" />
<!-- Main column (editor + toolbar) -->
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
<input
v-if="slug === 'new'"
v-model="docPath"
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-48 text-slate-900 dark:text-slate-100"
placeholder="New filename (e.g. folder/doc)"
/>
<!-- Toolbar -->
<div
class="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-800/60 flex-shrink-0 flex-wrap"
>
<!-- Editor mode tabs (hidden when viewing old version or diff) -->
<template v-if="!viewingVersion && !diffActive">
<button
:class="['px-3 py-1 rounded text-sm', editorMode === 'visual' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
@click="editorMode = 'visual'"
>Visual</button>
<button
:class="['px-3 py-1 rounded text-sm', editorMode === 'source' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
@click="editorMode = 'source'"
>Source</button>
</template>
<input
v-model="commitMsg"
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-64 text-slate-900 dark:text-slate-100"
placeholder="Commit message"
/>
<button
class="px-4 py-1 bg-green-700 hover:bg-green-600 rounded text-sm disabled:opacity-50"
:disabled="saving"
@click="save"
>{{ saving ? 'Saving…' : 'Save' }}</button>
<!-- Version view mode tabs -->
<template v-else-if="viewingVersion && !diffActive">
<button
:class="['px-3 py-1 rounded text-sm', versionEditorMode === 'visual' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
@click="versionEditorMode = 'visual'"
>Visual</button>
<button
:class="['px-3 py-1 rounded text-sm', versionEditorMode === 'source' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
@click="versionEditorMode = 'source'"
>Source</button>
</template>
<div class="flex-1" />
<!-- "Loading diff" spinner -->
<span v-if="diffLoading" class="text-sm text-slate-400 animate-pulse">Loading diff</span>
<!-- New doc path input -->
<input
v-if="slug === 'new' && !viewingVersion && !diffActive"
v-model="docPath"
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-48 text-slate-900 dark:text-slate-100"
placeholder="New filename (e.g. folder/doc)"
/>
<!-- Save controls (normal editing only) -->
<template v-if="!viewingVersion && !diffActive">
<input
v-model="commitMsg"
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-64 text-slate-900 dark:text-slate-100"
placeholder="Commit message"
/>
<button
class="px-4 py-1 bg-green-700 hover:bg-green-600 text-white rounded text-sm disabled:opacity-50"
:disabled="saving"
@click="save"
>{{ saving ? 'Saving…' : 'Save' }}</button>
</template>
<!-- History toggle button (not shown for new docs or in diff view) -->
<button
v-if="slug !== 'new'"
:class="[
'flex items-center gap-1.5 px-3 py-1 rounded text-sm transition-colors',
historyPanelOpen
? 'bg-indigo-600 text-white'
: 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200',
]"
title="Toggle history panel"
@click="toggleHistory"
>
<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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
History
</button>
</div>
<!-- Historical version banner -->
<div
v-if="viewingVersion && !diffActive"
class="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-700/40 flex-shrink-0 flex-wrap text-sm"
>
<svg class="w-4 h-4 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-amber-800 dark:text-amber-200 font-medium">
Viewing version
<span class="font-mono text-xs bg-amber-100 dark:bg-amber-800/60 px-1.5 py-0.5 rounded ml-1">{{ viewingVersion.hash.slice(0, 7) }}</span>
</span>
<span class="text-amber-600 dark:text-amber-300 text-xs">
{{ viewingVersion.author }} · {{ formatDate(viewingVersion.date) }}
</span>
<div class="flex-1" />
<button
class="px-3 py-1 rounded text-xs bg-blue-100 dark:bg-blue-900/40 hover:bg-blue-200 dark:hover:bg-blue-800/60 text-blue-700 dark:text-blue-300 transition-colors"
@click="compareVersionWithLatest"
>Compare with latest</button>
<button
class="px-3 py-1 rounded text-xs bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 transition-colors"
@click="exitVersionView"
> Back to current</button>
</div>
<!-- Editor / Viewer area -->
<div class="flex-1 overflow-auto min-h-0">
<!-- Diff view (full area) -->
<DiffViewer
v-if="diffActive"
:unified-diff="diffUnified"
:old-content="diffOldContent"
:new-content="diffNewContent"
:old-label="diffOldLabel"
:new-label="diffNewLabel"
class="h-full"
@close="exitDiffView"
/>
<!-- Historical version viewer (read-only) -->
<template v-else-if="viewingVersion">
<div v-if="versionLoading" class="p-6 text-slate-400 animate-pulse">Loading version</div>
<VisualEditor
v-else-if="versionEditorMode === 'visual'"
:model-value="versionContent"
@update:model-value="() => {}"
/>
<SourceEditor
v-else
:model-value="versionContent"
@update:model-value="() => {}"
/>
</template>
<!-- Normal editor -->
<template v-else>
<div v-if="loading" class="p-6 text-slate-400 animate-pulse">Loading…</div>
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" />
<SourceEditor v-else v-model="content" />
</template>
</div>
</div>
<!-- Editor -->
<div class="flex-1 overflow-auto">
<div v-if="loading" class="p-6 text-slate-400 animate-pulse">Loading</div>
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" />
<SourceEditor v-else v-model="content" />
</div>
<!-- ── Right panel: History ────────────────────────────────────────────── -->
<HistoryPanel
:slug="slug"
:is-open="historyPanelOpen"
@close="historyPanelOpen = false"
@view-version="onViewVersion"
@compare-with-latest="onCompareWithLatest"
/>
</div>
</template>