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,149 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '../stores/auth'
import { useToast } from '../stores/toast'
import LogoMark from '../components/LogoMark.vue'
import ShareLinkCard from '../components/ShareLinkCard.vue'
import SectionConnections from '../components/SectionConnections.vue'
import SectionRequests from '../components/SectionRequests.vue'
import SectionSettings from '../components/SectionSettings.vue'
const auth = useAuth()
const toast = useToast()
const route = useRoute()
const router = useRouter()
const tab = ref<'overview' | 'requests' | 'calendars' | 'settings'>('overview')
const loading = ref(true)
const tabs = [
{ value: 'overview', label: 'Übersicht' },
{ value: 'requests', label: 'Anfragen' },
{ value: 'calendars', label: 'Kalender' },
{ value: 'settings', label: 'Einstellungen' },
] as const
onMounted(async () => {
await auth.init()
if (!auth.user) {
auth.logout()
router.push({ name: 'login' })
return
}
loading.value = false
if (route.query.google === 'ok') {
toast.ok('Google Kalender verbunden 🎉')
tab.value = 'calendars'
} else if (route.query.google === 'error') {
toast.error('Google-Verbindung fehlgeschlagen: ' + (route.query.reason || 'unbekannter Fehler'))
tab.value = 'calendars'
} else if (typeof route.query.tab === 'string') {
tab.value = route.query.tab as typeof tab.value
}
if (Object.keys(route.query).length > 0) {
router.replace({ query: {} })
}
})
function logout() {
auth.logout()
router.push('/')
}
</script>
<template>
<main class="page dash" v-if="!loading && auth.user">
<header class="glass dash-header">
<div class="row">
<LogoMark />
<strong class="brand">WannPassts</strong>
</div>
<div class="row">
<span class="muted small hide-sm">{{ auth.user.email }}</span>
<button class="btn btn-sm" @click="logout">Abmelden</button>
</div>
</header>
<nav class="tabs">
<button
v-for="t in tabs"
:key="t.value"
class="tab"
:class="{ active: tab === t.value }"
@click="tab = t.value"
>
{{ t.label }}
</button>
</nav>
<div v-if="tab === 'overview'" class="col overview">
<ShareLinkCard />
<div class="overview-grid">
<router-link :to="{ query: { tab: 'calendars' } }" class="glass tile" @click="tab = 'calendars'">
<span class="tile-icon">📆</span>
<strong>Kalender verbinden</strong>
<span class="muted small">Google, iCloud/CalDAV oder ICS-Link hinzufügen</span>
</router-link>
<router-link :to="{ query: { tab: 'requests' } }" class="glass tile" @click="tab = 'requests'">
<span class="tile-icon">📨</span>
<strong>Anfragen prüfen</strong>
<span class="muted small">Buchungsanfragen annehmen oder ablehnen</span>
</router-link>
<router-link :to="{ query: { tab: 'settings' } }" class="glass tile" @click="tab = 'settings'">
<span class="tile-icon"></span>
<strong>Buchungsregeln</strong>
<span class="muted small">Zeitfenster, Dauern, Slot-Größe festlegen</span>
</router-link>
</div>
<section class="glass privacy">
<strong>🔒 Datenschutz by Design</strong>
<p class="muted small" style="margin: 6px 0 0">
Besucher deiner Buchungsseite sehen ausschließlich <em>frei</em> oder <em>belegt</em>
niemals Titel, Ort oder Beschreibung deiner Termine. Google-Anbindungen nutzen die
FreeBusy-API, die strukturell keine Termindetails liefert.
</p>
</section>
</div>
<SectionRequests v-else-if="tab === 'requests'" />
<SectionConnections v-else-if="tab === 'calendars'" />
<SectionSettings v-else />
</main>
<main v-else class="page" style="display: flex; justify-content: center; padding-top: 20vh">
<div class="skeleton" style="width: 320px; height: 120px"></div>
</main>
</template>
<style scoped>
.dash { display: flex; flex-direction: column; gap: 18px; }
.dash-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
}
.brand { font-size: 17px; letter-spacing: -0.01em; }
.overview { gap: 18px; }
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 22px;
text-decoration: none;
color: var(--text);
transition: transform 0.15s ease, border-color 0.15s ease;
cursor: pointer;
}
.tile:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.24); }
.tile-icon { font-size: 26px; }
.privacy { padding: 20px 24px; }
@media (max-width: 520px) { .hide-sm { display: none; } }
</style>