feat: add WannPassts booking app with Go backend and Vue frontend

Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
This commit is contained in:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

View file

@ -0,0 +1,239 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ApiError, api } from '../lib/api'
import type { Connection } from '../lib/types'
import { useToast } from '../stores/toast'
import GlassCard from './GlassCard.vue'
const toast = useToast()
const connections = ref<Connection[] | null>(null)
const syncing = ref(false)
const busy = ref(false)
const showCalDAV = ref(false)
const showICS = ref(false)
const caldav = ref({ server_url: 'https://caldav.icloud.com', username: '', password: '', calendar_path: '', display_name: '' })
const ics = ref({ url: '', display_name: '' })
let pollTimer: ReturnType<typeof setTimeout> | null = null
const providerLabel: Record<Connection['provider'], string> = {
google: 'Google',
caldav: 'CalDAV / iCloud',
ics: 'ICS-Abo',
}
const providerIcon: Record<Connection['provider'], string> = {
google: '🇬',
caldav: '☁️',
ics: '📡',
}
async function load() {
try {
connections.value = await api.connections()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Laden fehlgeschlagen')
}
}
function schedulePoll() {
pollTimer = setTimeout(load, 3500)
}
onMounted(() => {
load()
schedulePoll()
})
onUnmounted(() => {
if (pollTimer) clearTimeout(pollTimer)
})
async function syncNow() {
syncing.value = true
try {
connections.value = await api.syncNow()
toast.ok('Kalender synchronisiert')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Sync fehlgeschlagen')
} finally {
syncing.value = false
}
}
async function connectGoogle() {
try {
const { url } = await api.googleStart()
location.href = url
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Google-Start fehlgeschlagen')
}
}
async function submitCalDAV() {
busy.value = true
try {
await api.connectCalDAV({
server_url: caldav.value.server_url,
username: caldav.value.username,
password: caldav.value.password,
calendar_path: caldav.value.calendar_path || undefined,
display_name: caldav.value.display_name || undefined,
})
toast.ok('Kalender verbunden erste Synchronisierung läuft.')
showCalDAV.value = false
caldav.value.username = ''
caldav.value.password = ''
await load()
schedulePoll()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
} finally {
busy.value = false
}
}
async function submitICS() {
busy.value = true
try {
await api.connectICS({ url: ics.value.url, display_name: ics.value.display_name || undefined })
toast.ok('ICS-Kalender verbunden.')
showICS.value = false
ics.value.url = ''
ics.value.display_name = ''
await load()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
} finally {
busy.value = false
}
}
async function remove(conn: Connection) {
if (!confirm(`${conn.display_name}“ wirklich entfernen?`)) return
try {
await api.deleteConnection(conn.id)
connections.value = (connections.value ?? []).filter((c) => c.id !== conn.id)
toast.ok('Verbindung entfernt')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen')
}
}
function syncedAgo(ts: number | null): string {
if (!ts) return 'wird synchronisiert…'
const s = Math.max(0, Math.round(Date.now() / 1000 - ts))
if (s < 60) return `vor ${s}s`
if (s < 3600) return `vor ${Math.round(s / 60)} Min.`
return `vor ${Math.round(s / 3600)} Std.`
}
</script>
<template>
<div class="col">
<GlassCard title="Kalender hinzufügen" subtitle="WannPassts liest nur frei/belegt nie Termindetails.">
<div class="row add-row">
<button class="btn" @click="connectGoogle">🇬 Google Kalender</button>
<button class="btn" @click="showCalDAV = !showCalDAV; showICS = false"> iCloud / CalDAV</button>
<button class="btn" @click="showICS = !showICS; showCalDAV = false">📡 ICS-Link</button>
</div>
<transition name="fade">
<form v-if="showCalDAV" class="glass-soft form" @submit.prevent="submitCalDAV">
<p class="muted small" style="margin: 0 0 4px">
Für iCloud: <strong>Apple-ID</strong> als Benutzername und ein
<a href="https://appleid.apple.com/account/manage" target="_blank" rel="noopener">App-spezifisches Passwort</a>
(Konto Anmeldung und Sicherheit). Auch Fastmail, Nextcloud &amp; Co. funktionieren.
</p>
<div class="form-grid">
<label class="field">
<span>Server</span>
<input v-model="caldav.server_url" required class="input" />
</label>
<label class="field">
<span>Benutzername</span>
<input v-model="caldav.username" required class="input" autocomplete="username" />
</label>
<label class="field">
<span>Passwort (App-spezifisch)</span>
<input v-model="caldav.password" type="password" required class="input" autocomplete="current-password" />
</label>
<label class="field">
<span>Kalender-Pfad (optional)</span>
<input v-model="caldav.calendar_path" class="input" placeholder="wird automatisch erkannt" />
</label>
<label class="field">
<span>Anzeigename (optional)</span>
<input v-model="caldav.display_name" class="input" placeholder="z. B. Privatkalender" />
</label>
</div>
<button class="btn btn-primary" type="submit" :disabled="busy">
{{ busy ? 'Verbinde…' : 'Verbinden' }}
</button>
</form>
</transition>
<transition name="fade">
<form v-if="showICS" class="glass-soft form" @submit.prevent="submitICS">
<p class="muted small" style="margin: 0 0 4px">
Funktioniert mit jedem öffentlichen ICS/webcal-Link, z. B. der privaten Adresse im
iCal-Format eines Google-Kalenders oder Ferien-/Schichtplänen.
</p>
<label class="field">
<span>ICS-URL</span>
<input v-model="ics.url" required class="input" placeholder="https://…/kalender.ics" />
</label>
<label class="field">
<span>Anzeigename (optional)</span>
<input v-model="ics.display_name" class="input" placeholder="z. B. Schichtplan" />
</label>
<button class="btn btn-primary" type="submit" :disabled="busy">
{{ busy ? 'Prüfe…' : 'Hinzufügen' }}
</button>
</form>
</transition>
</GlassCard>
<GlassCard title="Verbundene Kalender">
<template #header>
<button class="btn btn-sm" :disabled="syncing" @click="syncNow">
{{ syncing ? 'Synchronisiere…' : '↻ Jetzt synchronisieren' }}
</button>
</template>
<div v-if="connections === null" class="skeleton" style="height: 72px"></div>
<p v-else-if="connections.length === 0" class="muted small" style="margin: 0">
Noch kein Kalender verbunden. Oben einen hinzufügen ohne Kalender sind alle Zeiten frei.
</p>
<div v-else class="col conn-list">
<div v-for="c in connections" :key="c.id" class="glass-soft conn">
<span class="conn-icon">{{ providerIcon[c.provider] }}</span>
<div class="grow">
<div class="row">
<strong>{{ c.display_name }}</strong>
<span class="badge">{{ providerLabel[c.provider] }}</span>
</div>
<div class="row small">
<span :class="c.last_error ? 'err' : 'muted'">
{{ c.last_error ? '⚠ ' + c.last_error : '✓ synchronisiert ' + syncedAgo(c.last_synced_ts) }}
</span>
</div>
</div>
<button class="btn btn-sm btn-danger" @click="remove(c)">Entfernen</button>
</div>
</div>
</GlassCard>
</div>
</template>
<style scoped>
.add-row { flex-wrap: wrap; }
.form { padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.form a { color: var(--accent); }
.conn { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
.conn-icon { font-size: 22px; }
.err { color: var(--danger); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
</style>