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:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
239
frontend/src/components/SectionConnections.vue
Normal file
239
frontend/src/components/SectionConnections.vue
Normal 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 & 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue