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:
parent
f220d96e3b
commit
cbc03c56bf
18 changed files with 277 additions and 9 deletions
|
|
@ -3,8 +3,14 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<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>
|
||||
// Design vor dem ersten Render anwenden (verhindert Flash beim Laden)
|
||||
;(function () {
|
||||
|
|
|
|||
14
frontend/public/icons/apple-touch-icon.svg
Normal file
14
frontend/public/icons/apple-touch-icon.svg
Normal 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 |
13
frontend/public/icons/icon-maskable.svg
Normal file
13
frontend/public/icons/icon-maskable.svg
Normal 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 |
13
frontend/public/icons/icon.svg
Normal file
13
frontend/public/icons/icon.svg
Normal 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 |
22
frontend/public/manifest.webmanifest
Normal file
22
frontend/public/manifest.webmanifest
Normal 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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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 a { color: var(--accent); }
|
||||
.conn { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
|
||||
.conn .grow { min-width: 0; }
|
||||
.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:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -101,4 +101,17 @@ const statusLabel = { pending: 'Offen', accepted: 'Angenommen', declined: 'Abgel
|
|||
font-size: 14px;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -43,4 +43,13 @@ async function copy() {
|
|||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.link-row .link {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.link-row .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,3 +11,12 @@ const app = createApp(App)
|
|||
app.use(createPinia())
|
||||
app.use(router)
|
||||
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 */
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ html[data-theme='light'] {
|
|||
--ok: #0c9e63;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
|
|
@ -88,11 +92,22 @@ body {
|
|||
linear-gradient(180deg, var(--bg-1), var(--bg-0));
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
letter-spacing: 0.01em;
|
||||
overflow-wrap: break-word;
|
||||
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 */
|
||||
.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); }
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,8 +276,7 @@ onMounted(async () => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking { display: flex; flex-direction: column; gap: 16px; max-width: 780px; }
|
||||
.state {
|
||||
.booking { display: flex; flex-direction: column; gap: 16px; max-width: 780px; }.state {
|
||||
margin-top: 18vh;
|
||||
padding: 48px 36px;
|
||||
display: flex;
|
||||
|
|
@ -299,7 +298,7 @@ onMounted(async () => {
|
|||
box-shadow: 0 14px 44px rgba(70, 224, 165, 0.4);
|
||||
}
|
||||
.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-row { justify-content: space-between; flex-wrap: wrap; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
||||
|
|
@ -311,4 +310,13 @@ onMounted(async () => {
|
|||
.chosen { margin: 0; font-size: 15px; }
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ function logout() {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
.brand { font-size: 17px; letter-spacing: -0.01em; }
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import ThemeToggle from '../components/ThemeToggle.vue'
|
|||
gap: 22px;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding-top: 6vh;
|
||||
}
|
||||
|
||||
|
|
@ -106,4 +107,10 @@ h1 {
|
|||
.foot { text-align: center; }
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ async function submit() {
|
|||
<style scoped>
|
||||
.auth {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ async function submit() {
|
|||
<style scoped>
|
||||
.auth {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,95 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
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({
|
||||
plugins: [vue()],
|
||||
plugins: [vue(), serviceWorkerPlugin()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue