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
239 lines
8.4 KiB
Vue
239 lines
8.4 KiB
Vue
<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>
|