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:
2026-04-10 13:07:01 +02:00
parent 3db6fec664
commit e6b64e3344
35 changed files with 1882 additions and 74 deletions

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

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

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

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

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