feat: add PWA support and mobile-responsive layouts

- Add web app manifest, maskable/apple-touch icons, and a production-only
  service worker that caches the app shell for offline start
- Improve mobile UX: responsive breakpoints from ~320px, 44px touch
  targets, safe-area insets, dvh heights, 16px inputs to avoid iOS zoom
- Serve .webmanifest with correct content type in the Go static handler
- Document PWA behavior and mobile features in the README
This commit is contained in:
Tronax 2026-08-25 19:12:12 +02:00
parent f220d96e3b
commit cbc03c56bf
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
18 changed files with 277 additions and 9 deletions

View file

@ -18,6 +18,8 @@ Kalender nie.
- 📨 **Buchungsanfragen** annehmen/ablehnen; angenommene Zeiten werden belegt und verhindern Doppelbuchungen - 📨 **Buchungsanfragen** annehmen/ablehnen; angenommene Zeiten werden belegt und verhindern Doppelbuchungen
- ⚙️ Buchungsregeln: Zeitfenster, Slot-Raster (1060 Min), Dauern, Wochentage, Horizont (1120 Tage), Zeitzone - ⚙️ Buchungsregeln: Zeitfenster, Slot-Raster (1060 Min), Dauern, Wochentage, Horizont (1120 Tage), Zeitzone
- 🧊 **Liquid-Glass-UI** mit **Dark-/Light-Mode**: Umschalter (Auto / Dunkel / Hell) auf jeder Seite, automatische Systemerkennung (`prefers-color-scheme`) inkl. Live-Wechsel, Auswahl wird pro Browser gespeichert - 🧊 **Liquid-Glass-UI** mit **Dark-/Light-Mode**: Umschalter (Auto / Dunkel / Hell) auf jeder Seite, automatische Systemerkennung (`prefers-color-scheme`) inkl. Live-Wechsel, Auswahl wird pro Browser gespeichert
- 📱 **Voll mobilfähig**: responsive Layouts ab ~320 px, 44px-Touch-Targets, kein iOS-Fokus-Zoom (16px-Inputs), Safe-Area-Unterstützung (Notch), `dvh`-Höhen, `touch-action` ohne Doppeltipp-Zoom
- 📲 **PWA**: installierbar (Android/Desktop: „App installieren“, iOS: „Zum Home-Bildschirm“), Manifest mit Maskable-Icons, App-Shortcuts; Service Worker cached die App-Shell → Startseite funktioniert offline (API-Aufrufe brauchen natürlich Netz)
- 🔒 Provider-Tokens werden **AES-256-GCM-verschlüsselt** in der SQLite-DB gespeichert - 🔒 Provider-Tokens werden **AES-256-GCM-verschlüsselt** in der SQLite-DB gespeichert
## Tech Stack ## Tech Stack
@ -58,6 +60,13 @@ go run ./cmd/server
Der Go-Server liefert dann das Frontend (`dist/`) **und** die API über einen Port. Der Go-Server liefert dann das Frontend (`dist/`) **und** die API über einen Port.
### PWA-Hinweise
- Der Service Worker wird nur im Production-Build registriert (`npm run build` + Auslieferung über `STATIC_DIR`), nicht im Vite-Dev-Modus.
- Die Cache-Version des Service Workers wird pro Build automatisch neu gesetzt (Build-Zeitstempel) — nach einem Deploy holen Clients die neue Version und alte Caches werden geräumt.
- `/api/*` wird niemals gecached; offline funktioniert die App-Shell (Startseite/Navigation), Kalender- und Buchungsdaten benötigen eine Verbindung.
- Icons liegen als SVG-Quellen in `frontend/public/icons/` und werden per `rsvg-convert` zu PNGs konvertiert (Neuerzeugung: siehe Skript unten im Abschnitt „Tests“).
## Kalender-Anbindung einrichten ## Kalender-Anbindung einrichten
### Google ### Google
@ -130,3 +139,13 @@ frontend/
```bash ```bash
cd backend && go test ./... # ICS-Parser, Dauer-Handling, Interval-Clamping cd backend && go test ./... # ICS-Parser, Dauer-Handling, Interval-Clamping
``` ```
PWA-Icons neu erzeugen (benötigt `rsvg-convert`):
```bash
cd frontend/public/icons
rsvg-convert -w 192 -h 192 -o icon-192.png icon.svg
rsvg-convert -w 512 -h 512 -o icon-512.png icon.svg
rsvg-convert -w 512 -h 512 -o icon-maskable-512.png icon-maskable.svg
rsvg-convert -w 180 -h 180 -o apple-touch-icon.png apple-touch-icon.svg
```

View file

@ -78,6 +78,9 @@ func (s *Server) Router() http.Handler {
static := s.Cfg.StaticDir static := s.Cfg.StaticDir
r.Get("/*", func(w http.ResponseWriter, req *http.Request) { r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
p := filepath.Join(static, filepath.Clean(req.URL.Path)) p := filepath.Join(static, filepath.Clean(req.URL.Path))
if strings.HasSuffix(p, ".webmanifest") {
w.Header().Set("Content-Type", "application/manifest+json")
}
if st, err := os.Stat(p); err == nil && !st.IsDir() { if st, err := os.Stat(p); err == nil && !st.IsDir() {
http.ServeFile(w, req, p) http.ServeFile(w, req, p)
return return

View file

@ -3,8 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#05070c" /> <meta name="theme-color" content="#05070c" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="WannPassts" />
<script> <script>
// Design vor dem ersten Render anwenden (verhindert Flash beim Laden) // Design vor dem ersten Render anwenden (verhindert Flash beim Laden)
;(function () { ;(function () {

View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7db0ff"/>
<stop offset="1" stop-color="#b78cff"/>
</linearGradient>
</defs>
<!-- Apple Touch Icon: vollflächig, ohne Transparenz (iOS maskiert selbst) -->
<rect width="512" height="512" fill="url(#g)"/>
<rect x="112" y="132" width="288" height="268" rx="46" fill="#0b0f1a" opacity="0.88"/>
<path d="M186 104v52M326 104v52" stroke="#ffffff" stroke-width="26" stroke-linecap="round" opacity="0.95"/>
<path d="M118 198h276" stroke="#ffffff" stroke-width="10" stroke-linecap="round" opacity="0.22"/>
<path d="M186 272l52 52 92-92" stroke="#ffffff" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 819 B

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7db0ff"/>
<stop offset="1" stop-color="#b78cff"/>
</linearGradient>
</defs>
<!-- Maskable: Vollflächiger Hintergrund, Glyphe komplett in der 80%-Sicherheitszone -->
<rect width="512" height="512" fill="url(#g)"/>
<rect x="146" y="166" width="220" height="196" rx="36" fill="#0b0f1a" opacity="0.88"/>
<path d="M196 134v46M316 134v46" stroke="#ffffff" stroke-width="22" stroke-linecap="round" opacity="0.95"/>
<path d="M190 264l40 40 72-72" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 729 B

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7db0ff"/>
<stop offset="1" stop-color="#b78cff"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#g)"/>
<rect x="112" y="132" width="288" height="268" rx="46" fill="#0b0f1a" opacity="0.88"/>
<path d="M186 104v52M326 104v52" stroke="#ffffff" stroke-width="26" stroke-linecap="round" opacity="0.95"/>
<path d="M118 198h276" stroke="#ffffff" stroke-width="10" stroke-linecap="round" opacity="0.22"/>
<path d="M186 272l52 52 92-92" stroke="#ffffff" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 746 B

View file

@ -0,0 +1,22 @@
{
"name": "WannPassts",
"short_name": "WannPassts",
"description": "Deine freien Zeiten geteilt per Link. Verbinde Google-, iCloud- und andere Kalender und nimm Buchungsanfragen an, ohne deine Termine preiszugeben.",
"lang": "de",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#05070c",
"theme_color": "#05070c",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Anfragen", "url": "/app?tab=requests" },
{ "name": "Kalender", "url": "/app?tab=calendars" }
]
}

View file

@ -232,8 +232,16 @@ function syncedAgo(ts: number | null): string {
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.form a { color: var(--accent); } .form a { color: var(--accent); }
.conn { display: flex; align-items: center; gap: 14px; padding: 14px 16px; } .conn { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
.conn .grow { min-width: 0; }
.conn-icon { font-size: 22px; } .conn-icon { font-size: 22px; }
.err { color: var(--danger); } .err { color: var(--danger); overflow-wrap: anywhere; }
@media (max-width: 640px) {
.conn { flex-wrap: wrap; }
.conn .btn {
width: 100%;
}
}
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
</style> </style>

View file

@ -101,4 +101,17 @@ const statusLabel = { pending: 'Offen', accepted: 'Angenommen', declined: 'Abgel
font-size: 14px; font-size: 14px;
white-space: pre-wrap; white-space: pre-wrap;
} }
@media (max-width: 640px) {
.req-head {
flex-direction: column;
align-items: stretch;
}
.req-actions {
justify-content: stretch;
}
.req-actions .btn {
flex: 1;
}
}
</style> </style>

View file

@ -43,4 +43,13 @@ async function copy() {
white-space: nowrap; white-space: nowrap;
color: var(--accent); color: var(--accent);
} }
@media (max-width: 560px) {
.link-row .link {
flex-basis: 100%;
}
.link-row .btn {
width: 100%;
}
}
</style> </style>

View file

@ -11,3 +11,12 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
// PWA: Service Worker registrieren (nur im Production-Build; in dev stört er HMR).
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* PWA offline-fallback nicht verfügbar App läuft trotzdem */
})
})
}

View file

@ -70,6 +70,10 @@ html[data-theme='light'] {
--ok: #0c9e63; --ok: #0c9e63;
} }
html {
-webkit-text-size-adjust: 100%;
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { html, body {
@ -88,11 +92,22 @@ body {
linear-gradient(180deg, var(--bg-1), var(--bg-0)); linear-gradient(180deg, var(--bg-1), var(--bg-0));
background-attachment: fixed; background-attachment: fixed;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
letter-spacing: 0.01em; letter-spacing: 0.01em;
overflow-wrap: break-word;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
#app { min-height: 100vh; } /* dvh berücksichtigt die dynamischen Browser-Leisten auf Mobilgeräten */
#app {
min-height: 100vh;
min-height: 100dvh;
}
/* Doppel-Tap-Zoom auf Steuerflächen unterbinden */
.btn, .tab, .slot-chip, .day-chip, .tile, .weekday {
touch-action: manipulation;
}
/* Farbliche "Orbs" hinter allem geben dem Glass etwas zum Brechen */ /* Farbliche "Orbs" hinter allem geben dem Glass etwas zum Brechen */
.bg-scene { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; } .bg-scene { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; }
@ -372,5 +387,23 @@ textarea.input { resize: vertical; min-height: 84px; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(6px); } .fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(6px); }
@media (max-width: 640px) { @media (max-width: 640px) {
.page { padding: 16px 14px 64px; } .page {
padding: 14px 12px calc(64px + env(safe-area-inset-bottom, 0px));
}
/* 16px verhindert den automatischen Zoom-Fokus von iOS Safari */
.input, select.input, textarea.input {
font-size: 16px;
}
.btn {
padding: 12px 18px;
}
.slot-chip {
padding: 14px 0;
}
.slot-grid {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
.toasts {
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
} }

View file

@ -276,8 +276,7 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.booking { display: flex; flex-direction: column; gap: 16px; max-width: 780px; } .booking { display: flex; flex-direction: column; gap: 16px; max-width: 780px; }.state {
.state {
margin-top: 18vh; margin-top: 18vh;
padding: 48px 36px; padding: 48px 36px;
display: flex; display: flex;
@ -299,7 +298,7 @@ onMounted(async () => {
box-shadow: 0 14px 44px rgba(70, 224, 165, 0.4); box-shadow: 0 14px 44px rgba(70, 224, 165, 0.4);
} }
.head { padding: 22px 24px; display: flex; flex-direction: column; gap: 12px; } .head { padding: 22px 24px; display: flex; flex-direction: column; gap: 12px; }
.head h1 { margin: 0; font-size: 22px; } .head h1 { margin: 0; font-size: 22px; overflow-wrap: anywhere; }
.legend { display: flex; align-items: center; gap: 8px; color: var(--text-muted); } .legend { display: flex; align-items: center; gap: 8px; color: var(--text-muted); }
.legend-row { justify-content: space-between; flex-wrap: wrap; } .legend-row { justify-content: space-between; flex-wrap: wrap; }
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; } .dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
@ -311,4 +310,13 @@ onMounted(async () => {
.chosen { margin: 0; font-size: 15px; } .chosen { margin: 0; font-size: 15px; }
.form { display: flex; flex-direction: column; gap: 14px; } .form { display: flex; flex-direction: column; gap: 14px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
@media (max-width: 640px) {
.state { margin-top: 8vh; padding: 36px 22px; }
.head { padding: 18px 16px; }
.head h1 { font-size: 19px; }
.block { padding: 16px; }
.day-chip { min-width: 64px; }
.form button[type='submit'] { width: 100%; }
}
</style> </style>

View file

@ -125,6 +125,8 @@ function logout() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
padding: 14px 20px; padding: 14px 20px;
} }
.brand { font-size: 17px; letter-spacing: -0.01em; } .brand { font-size: 17px; letter-spacing: -0.01em; }

View file

@ -53,6 +53,7 @@ import ThemeToggle from '../components/ThemeToggle.vue'
gap: 22px; gap: 22px;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
padding-top: 6vh; padding-top: 6vh;
} }
@ -106,4 +107,10 @@ h1 {
.foot { text-align: center; } .foot { text-align: center; }
a.btn { text-decoration: none; display: inline-block; } a.btn { text-decoration: none; display: inline-block; }
@media (max-width: 640px) {
.hero { padding: 46px 20px 38px; }
.row.center { flex-direction: column; align-items: stretch; }
.features { gap: 12px; }
}
</style> </style>

View file

@ -65,6 +65,7 @@ async function submit() {
<style scoped> <style scoped>
.auth { .auth {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

@ -73,6 +73,7 @@ async function submit() {
<style scoped> <style scoped>
.auth { .auth {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

@ -1,8 +1,95 @@
import { defineConfig } from 'vite' import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// Service Worker als Build-Artefakt: Die Cache-Version ist der Build-Zeitstempel,
// damit Browser nach jedem Deploy den SW aktualisieren und alte Caches räumen.
const SW_SOURCE = `
const VERSION = 'wp-__SW_VERSION__';
const CACHE_NAME = 'wannpassts-' + VERSION;
const PRECACHE = [
'/',
'/manifest.webmanifest',
'/favicon.svg',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-maskable-512.png',
'/icons/apple-touch-icon.png',
];
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(PRECACHE).catch(() => {});
await self.skipWaiting();
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// API-Aufrufe immer live niemals aus dem Cache bedienen.
if (url.pathname.startsWith('/api/')) return;
// Navigation: Netzwerk zuerst, offline App-Shell aus dem Cache.
if (req.mode === 'navigate') {
event.respondWith((async () => {
try {
const res = await fetch(req);
const cache = await caches.open(CACHE_NAME);
cache.put('/', res.clone());
return res;
} catch (err) {
const cached = (await caches.match(req)) || (await caches.match('/'));
if (cached) return cached;
return new Response('Offline bitte Internetverbindung prüfen.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
})());
return;
}
// Statische Assets: Cache zuerst (gehashte Namen sind unveränderlich).
event.respondWith((async () => {
const cached = await caches.match(req, { ignoreSearch: true });
if (cached) return cached;
const res = await fetch(req);
if (res.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(req, res.clone());
}
return res;
})());
});
`
function serviceWorkerPlugin(): Plugin {
return {
name: 'wannpassts-sw',
apply: 'build',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'sw.js',
source: SW_SOURCE.replace('__SW_VERSION__', new Date().toISOString()),
})
},
}
}
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue(), serviceWorkerPlugin()],
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {