feat: Archivum skeleton — Go/GraphQL backend + Vue 3 PWA frontend
Full project scaffold: multi-stage Dockerfile (ARM64/AMD64), AsciiDoc↔TipTap bridge, Setup Wizard, CodeMirror source editor, Git-backed storage layer, LDAP+JWT auth skeleton, Tailwind mobile-first layout, and VS Code build/push tasks targeting registry at 192.168.0.19:5000. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
23
frontend/src/App.vue
Normal file
23
frontend/src/App.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
// If backend signals REQUIRE_SETUP, redirect to wizard.
|
||||
app.checkStatus().then(() => {
|
||||
if (app.requiresSetup) {
|
||||
router.replace('/setup')
|
||||
}
|
||||
})
|
||||
|
||||
const showLayout = computed(() => !app.requiresSetup)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout v-if="showLayout" />
|
||||
<router-view v-else />
|
||||
</template>
|
||||
23
frontend/src/assets/main.css
Normal file
23
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
/* AsciiDoc rendered output */
|
||||
.adoc-content h1 { @apply text-2xl font-bold mb-4 mt-6; }
|
||||
.adoc-content h2 { @apply text-xl font-semibold mb-3 mt-5; }
|
||||
.adoc-content h3 { @apply text-lg font-medium mb-2 mt-4; }
|
||||
.adoc-content p { @apply mb-3 leading-relaxed; }
|
||||
.adoc-content pre { @apply bg-surface-800 rounded-lg p-4 overflow-x-auto font-mono text-sm mb-4; }
|
||||
.adoc-content code { @apply font-mono text-sm bg-surface-800 px-1 rounded; }
|
||||
.adoc-content ul { @apply list-disc list-inside mb-3 space-y-1; }
|
||||
.adoc-content ol { @apply list-decimal list-inside mb-3 space-y-1; }
|
||||
.adoc-content blockquote { @apply border-l-4 border-slate-500 pl-4 italic text-slate-400 mb-3; }
|
||||
.adoc-content table { @apply w-full text-sm border-collapse mb-4; }
|
||||
.adoc-content th { @apply bg-surface-800 px-3 py-2 text-left border border-slate-700; }
|
||||
.adoc-content td { @apply px-3 py-2 border border-slate-700; }
|
||||
}
|
||||
164
frontend/src/bridge/asciidoc-bridge.ts
Normal file
164
frontend/src/bridge/asciidoc-bridge.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 type { JSONContent } from '@tiptap/core'
|
||||
|
||||
const asciidoctor = Asciidoctor()
|
||||
|
||||
// ── AsciiDoc → TipTap ────────────────────────────────────────────────────────
|
||||
|
||||
export function toTipTap(adoc: string): JSONContent {
|
||||
if (!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[]
|
||||
|
||||
return {
|
||||
type: 'doc',
|
||||
content: content.length ? content : [{ type: 'paragraph' }],
|
||||
}
|
||||
}
|
||||
|
||||
// ── TipTap → AsciiDoc ────────────────────────────────────────────────────────
|
||||
|
||||
export function fromTipTap(json: JSONContent): string {
|
||||
if (!json.content?.length) return ''
|
||||
return json.content.map(nodeToAdoc).join('\n\n')
|
||||
}
|
||||
|
||||
// ── 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?.() ?? ''),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 ?? [])}`
|
||||
}
|
||||
|
||||
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 ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
47
frontend/src/components/editor/SourceEditor.vue
Normal file
47
frontend/src/components/editor/SourceEditor.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
let view: EditorView | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!container.value) return
|
||||
|
||||
view = new EditorView({
|
||||
parent: container.value,
|
||||
state: EditorState.create({
|
||||
doc: model.value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
markdown(),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
model.value = update.state.doc.toString()
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Sync external model changes into CodeMirror.
|
||||
watch(model, (adoc) => {
|
||||
if (!view) return
|
||||
const current = view.state.doc.toString()
|
||||
if (current !== adoc) {
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: adoc } })
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => view?.destroy())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="h-full font-mono text-sm" />
|
||||
</template>
|
||||
34
frontend/src/components/editor/VisualEditor.vue
Normal file
34
frontend/src/components/editor/VisualEditor.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
content: toTipTap(model.value),
|
||||
onUpdate({ editor }) {
|
||||
model.value = fromTipTap(editor.getJSON())
|
||||
},
|
||||
})
|
||||
|
||||
// Sync external changes (e.g. switching from SourceEditor) into TipTap.
|
||||
watch(model, (adoc) => {
|
||||
if (!editor.value) return
|
||||
const json = toTipTap(adoc)
|
||||
const current = JSON.stringify(editor.value.getJSON())
|
||||
if (JSON.stringify(json) !== current) {
|
||||
editor.value.commands.setContent(json, false)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => editor.value?.destroy())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 prose prose-invert max-w-none">
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
56
frontend/src/components/layout/AppLayout.vue
Normal file
56
frontend/src/components/layout/AppLayout.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden bg-surface-900 text-slate-100">
|
||||
<!-- Mobile overlay -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 z-20 bg-black/60 lg:hidden"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<!-- Sidebar — slide-over on mobile, static on desktop -->
|
||||
<aside
|
||||
:class="[
|
||||
'fixed inset-y-0 left-0 z-30 w-64 flex-shrink-0 bg-surface-800 border-r border-slate-700 transform transition-transform duration-200',
|
||||
'lg:static lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<Sidebar @close="sidebarOpen = false" />
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<!-- Mobile top bar -->
|
||||
<header class="flex items-center gap-3 p-3 border-b border-slate-700 lg:hidden">
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-surface-700"
|
||||
aria-label="Open navigation"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="font-semibold">Archivum</span>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
59
frontend/src/components/layout/Sidebar.vue
Normal file
59
frontend/src/components/layout/Sidebar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { gql } from '@/lib/gql'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
interface DocMeta { slug: string; title: string }
|
||||
|
||||
const docs = ref<DocMeta[]>([])
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await gql<{ documents: DocMeta[] }>(`{ documents { slug title } }`)
|
||||
docs.value = data.documents
|
||||
})
|
||||
|
||||
function navigate(slug: string) {
|
||||
router.push(`/doc/${slug}`)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||
<router-link to="/" class="font-bold text-lg" @click="emit('close')">Archivum</router-link>
|
||||
<button class="lg:hidden p-1 rounded hover:bg-surface-700" @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>
|
||||
|
||||
<!-- Document tree -->
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
<button
|
||||
v-for="doc in docs"
|
||||
:key="doc.slug"
|
||||
class="w-full text-left px-4 py-2 text-sm hover:bg-surface-700 truncate"
|
||||
@click="navigate(doc.slug)"
|
||||
>
|
||||
{{ doc.title }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- New document button -->
|
||||
<div class="p-3 border-t border-slate-700">
|
||||
<router-link
|
||||
to="/doc/new"
|
||||
class="block w-full text-center py-2 rounded bg-blue-700 hover:bg-blue-600 text-sm font-medium"
|
||||
@click="emit('close')"
|
||||
>
|
||||
+ New document
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
134
frontend/src/components/wizard/SetupWizard.vue
Normal file
134
frontend/src/components/wizard/SetupWizard.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { gql } from '@/lib/gql'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
|
||||
const step = ref(1)
|
||||
const error = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
storagePath: '/data/wiki',
|
||||
adminUser: 'admin',
|
||||
adminPass: '',
|
||||
jwtSecret: crypto.randomUUID().replace(/-/g, ''),
|
||||
ldapEnabled: false,
|
||||
ldap: {
|
||||
host: '',
|
||||
port: 389,
|
||||
baseDN: '',
|
||||
bindDN: '',
|
||||
bindPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
submitting.value = true
|
||||
try {
|
||||
await gql(
|
||||
`mutation Setup($i: SetupInput!) { setup(input: $i) }`,
|
||||
{
|
||||
i: {
|
||||
storagePath: form.storagePath,
|
||||
adminUser: form.adminUser,
|
||||
adminPass: form.adminPass,
|
||||
jwtSecret: form.jwtSecret,
|
||||
ldap: form.ldapEnabled ? form.ldap : null,
|
||||
},
|
||||
},
|
||||
)
|
||||
app.requiresSetup = false
|
||||
await app.login(form.adminUser, form.adminPass)
|
||||
router.replace('/')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-surface-900 px-4">
|
||||
<div class="w-full max-w-md bg-surface-800 rounded-2xl p-8 shadow-xl">
|
||||
<h1 class="text-2xl font-bold mb-1">Welcome to Archivum</h1>
|
||||
<p class="text-slate-400 text-sm mb-6">Complete setup to get started.</p>
|
||||
|
||||
<!-- Step 1: Storage & Admin -->
|
||||
<form v-if="step === 1" class="space-y-4" @submit.prevent="step = 2">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Storage path</label>
|
||||
<input v-model="form.storagePath" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Admin username</label>
|
||||
<input v-model="form.adminUser" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Admin password</label>
|
||||
<input v-model="form.adminPass" type="password" required class="input" />
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Next →</button>
|
||||
</form>
|
||||
|
||||
<!-- Step 2: LDAP (optional) -->
|
||||
<form v-else-if="step === 2" class="space-y-4" @submit.prevent="submit">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input v-model="form.ldapEnabled" type="checkbox" class="rounded" />
|
||||
<span class="text-sm">Enable LDAP authentication</span>
|
||||
</label>
|
||||
|
||||
<template v-if="form.ldapEnabled">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Host</label>
|
||||
<input v-model="form.ldap.host" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Port</label>
|
||||
<input v-model.number="form.ldap.port" type="number" required class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Base DN</label>
|
||||
<input v-model="form.ldap.baseDN" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Bind DN</label>
|
||||
<input v-model="form.ldap.bindDN" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Bind password</label>
|
||||
<input v-model="form.ldap.bindPassword" type="password" required class="input" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="button" class="btn-secondary flex-1" @click="step = 1">← Back</button>
|
||||
<button type="submit" class="btn-primary flex-1" :disabled="submitting">
|
||||
{{ submitting ? 'Setting up…' : 'Finish setup' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
@apply w-full bg-surface-900 border border-slate-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-blue-500;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply py-2 rounded-lg bg-blue-600 hover:bg-blue-500 font-medium text-sm disabled:opacity-50 transition;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply py-2 rounded-lg bg-surface-900 hover:bg-surface-700 font-medium text-sm transition;
|
||||
}
|
||||
</style>
|
||||
24
frontend/src/lib/gql.ts
Normal file
24
frontend/src/lib/gql.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Minimal GraphQL client — swap for graphql-request if preferred.
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? '/graphql'
|
||||
|
||||
export async function gql<T = unknown>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
if (json.errors?.length) {
|
||||
throw new Error(json.errors[0].message)
|
||||
}
|
||||
return json.data as T
|
||||
}
|
||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { router } from '@/router'
|
||||
import App from '@/App.vue'
|
||||
import '@/assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
27
frontend/src/router/index.ts
Normal file
27
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/doc/:slug(.*)',
|
||||
name: 'document',
|
||||
component: () => import('@/views/DocumentView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('@/components/wizard/SetupWizard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
},
|
||||
],
|
||||
})
|
||||
36
frontend/src/stores/app.ts
Normal file
36
frontend/src/stores/app.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const requiresSetup = ref(false)
|
||||
const token = ref<string | null>(localStorage.getItem('token'))
|
||||
const username = ref<string | null>(null)
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const data = await gql<{ systemStatus: string }>(`{ systemStatus }`)
|
||||
requiresSetup.value = data.systemStatus === 'REQUIRE_SETUP'
|
||||
} catch {
|
||||
requiresSetup.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function login(user: string, password: string) {
|
||||
const data = await gql<{ login: string }>(
|
||||
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
|
||||
{ u: user, p: password },
|
||||
)
|
||||
token.value = data.login
|
||||
username.value = user
|
||||
localStorage.setItem('token', data.login)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
username.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { requiresSetup, token, username, checkStatus, login, logout }
|
||||
})
|
||||
76
frontend/src/views/DocumentView.vue
Normal file
76
frontend/src/views/DocumentView.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { gql } from '@/lib/gql'
|
||||
import VisualEditor from '@/components/editor/VisualEditor.vue'
|
||||
import SourceEditor from '@/components/editor/SourceEditor.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
const content = ref('')
|
||||
const editorMode = ref<'visual' | 'source'>('visual')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const commitMsg = ref('Update document')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await gql<{ document: { content: string } }>(
|
||||
`query Doc($s: String!) { document(slug: $s) { content } }`,
|
||||
{ s: slug.value },
|
||||
)
|
||||
content.value = data.document?.content ?? ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await gql(
|
||||
`mutation Save($i: SaveDocumentInput!) { saveDocument(input: $i) { slug } }`,
|
||||
{ i: { slug: slug.value, content: content.value, commitMessage: commitMsg.value } },
|
||||
)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</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-surface-800 flex-wrap">
|
||||
<button
|
||||
:class="['px-3 py-1 rounded text-sm', editorMode === 'visual' ? 'bg-blue-600' : 'bg-surface-900 hover:bg-surface-700']"
|
||||
@click="editorMode = 'visual'"
|
||||
>Visual</button>
|
||||
<button
|
||||
:class="['px-3 py-1 rounded text-sm', editorMode === 'source' ? 'bg-blue-600' : 'bg-surface-900 hover:bg-surface-700']"
|
||||
@click="editorMode = 'source'"
|
||||
>Source</button>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<input
|
||||
v-model="commitMsg"
|
||||
class="bg-surface-900 border border-slate-600 rounded px-2 py-1 text-sm w-64"
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
49
frontend/src/views/HomeView.vue
Normal file
49
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
|
||||
interface DocMeta {
|
||||
slug: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const docs = ref<DocMeta[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await gql<{ documents: DocMeta[] }>(`{
|
||||
documents { slug title updatedAt }
|
||||
}`)
|
||||
docs.value = data.documents
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Archivum</h1>
|
||||
|
||||
<div v-if="loading" class="text-slate-400 animate-pulse">Loading…</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-if="docs.length === 0" class="text-slate-500">No documents yet.</li>
|
||||
<li
|
||||
v-for="doc in docs"
|
||||
:key="doc.slug"
|
||||
class="rounded-lg bg-surface-800 hover:bg-surface-700 transition"
|
||||
>
|
||||
<router-link
|
||||
:to="`/doc/${doc.slug}`"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span class="font-medium">{{ doc.title }}</span>
|
||||
<span class="text-xs text-slate-500">{{ doc.updatedAt }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user