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,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>
|
||||
|
||||
354
frontend/src/components/history/DiffViewer.vue
Normal file
354
frontend/src/components/history/DiffViewer.vue
Normal 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>
|
||||
170
frontend/src/components/history/HistoryPanel.vue
Normal file
170
frontend/src/components/history/HistoryPanel.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user