feat: PWA-Unterstützung und Mobile-Responsiveness
Macht Calendarr installierbar (Manifest + Service Worker) und auf Smartphones bedienbar — additive Änderungen, kein Refactoring der bestehenden Logik, Theme/Variablen unverändert. PWA: - frontend/manifest.json (theme #4285f4, bg #0e0e14, name/icons/scope) - frontend/sw.js (cache-first für Statics, network-first für /api/*) - frontend/icons/icon-192.png + icon-512.png + icon.svg - backend/main.py: Routen für /manifest.json, /sw.js, /icons/* damit diese Pfade nicht vom SPA-Fallback abgefangen werden - index.html: manifest-Link, theme-color, apple-touch-icon, apple-* Meta - app.js: Service-Worker-Registrierung am Ende Mobile (≤ 768px, additiv am Ende von app.css): - Sidebar als Overlay mit body.sidebar-open + Backdrop-Element - View-Switcher horizontal scrollbar wenn er nicht passt - Monatsansicht zeigt nur farbige Punkte statt Titel - Wochenansicht reduziert auf Tagesspalte (heute) wenn heute in der Woche ist (via :has()), sonst Standard-7-Spalten - Modale auf voller Breite/Höhe - Tap-Targets ≥ 44px (icon-btn, btn) - Kein horizontaler Page-Overflow - iOS-Safe-Area für Notch/Home-Indicator Version v2 → v3.
This commit is contained in:
@@ -115,6 +115,29 @@ FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
|
||||
|
||||
# ── PWA assets that must live at root scope ──────────────
|
||||
@app.get("/manifest.json")
|
||||
async def pwa_manifest():
|
||||
return FileResponse(str(FRONTEND_DIR / "manifest.json"), media_type="application/manifest+json")
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def pwa_service_worker():
|
||||
return FileResponse(
|
||||
str(FRONTEND_DIR / "sw.js"),
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/icons/{icon_name}")
|
||||
async def pwa_icon(icon_name: str):
|
||||
icon_path = FRONTEND_DIR / "icons" / icon_name
|
||||
if not icon_path.exists() or not icon_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Icon not found")
|
||||
return FileResponse(str(icon_path))
|
||||
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa_fallback(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
|
||||
@@ -1211,3 +1211,146 @@ a { color: var(--primary); text-decoration: none; }
|
||||
margin-top: 12px; padding-top: 10px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* ── Mobile / PWA additions ─────────────────────────────────
|
||||
Additive only — does not modify any existing rules above. */
|
||||
|
||||
/* Backdrop element exists in DOM but is hidden by default on desktop */
|
||||
.sidebar-backdrop { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html, body { overflow-x: hidden; max-width: 100vw; }
|
||||
|
||||
/* ── Sidebar slides in as overlay on mobile ─────────────── */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(85vw, 320px);
|
||||
z-index: 600;
|
||||
transform: translateX(-100%);
|
||||
transition: transform .25s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
body.sidebar-open .sidebar { transform: translateX(0); }
|
||||
/* Neutralize the desktop .collapsed shift on mobile so the JS toggle
|
||||
never creates a second hidden state to fight with .sidebar-open */
|
||||
.sidebar.collapsed { transform: translateX(-100%); margin-right: 0 !important; }
|
||||
body.sidebar-open .sidebar.collapsed { transform: translateX(0); }
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.55);
|
||||
z-index: 500;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
body.sidebar-open .sidebar-backdrop {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Topbar tightening + scrollable view switcher ────────── */
|
||||
.topbar { padding: 0 8px; gap: 4px; }
|
||||
.topbar-left { width: auto; flex-shrink: 0; }
|
||||
.topbar-center {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topbar-center .view-title {
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topbar-right {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.view-switcher {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.view-switcher::-webkit-scrollbar { display: none; }
|
||||
.view-switcher .view-btn { flex: 0 0 auto; }
|
||||
|
||||
/* ── 44px minimum tap targets ────────────────────────────── */
|
||||
.icon-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.btn { min-height: 44px; }
|
||||
.view-switcher .view-btn { min-height: 40px; }
|
||||
|
||||
/* ── Month view: dots instead of full event titles ───────── */
|
||||
.month-span-event {
|
||||
height: 6px !important;
|
||||
line-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 3px !important;
|
||||
font-size: 0 !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
.month-events-overlay { gap: 1px; }
|
||||
.month-more {
|
||||
font-size: 9px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.cell-day { font-size: 11px; }
|
||||
|
||||
/* ── Week view collapses to single day on mobile when today
|
||||
is in the visible week. Falls back to default 7-col layout
|
||||
for other weeks (use day view there). ──────────────────── */
|
||||
.week-header-row:has(.week-day-header.today) .week-day-header:not(.today) { display: none; }
|
||||
.week-header-row:has(.week-day-header.today) .week-day-header.today { flex: 1 1 100%; width: 100%; }
|
||||
.week-days-col:has(.week-day-col.today) .week-day-col:not(.today) { display: none; }
|
||||
.week-days-col:has(.week-day-col.today) .week-day-col.today { flex: 1 1 100%; width: 100%; }
|
||||
.week-allday-row:has(.week-day-col.today) .week-day-col:not(.today) { display: none; }
|
||||
|
||||
/* ── Modals: full-screen on mobile ───────────────────────── */
|
||||
.modal-overlay { padding: 0; }
|
||||
.modal-card {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.modal-footer { flex-shrink: 0; }
|
||||
|
||||
/* Settings page modal: also full-screen */
|
||||
.settings-page-card {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ── Misc safety: prevent overflow on flex topbar items ──── */
|
||||
.main-view { width: 100%; min-width: 0; }
|
||||
#view-container { max-width: 100%; overflow-x: hidden; }
|
||||
}
|
||||
|
||||
/* iOS notch / home-indicator safe areas (PWA standalone) */
|
||||
@supports (padding: env(safe-area-inset-top)) {
|
||||
.topbar { padding-top: env(safe-area-inset-top); height: calc(var(--topbar-h) + env(safe-area-inset-top)); }
|
||||
.sidebar { padding-bottom: env(safe-area-inset-bottom); }
|
||||
}
|
||||
|
||||
|
||||
BIN
frontend/icons/icon-192.png
Normal file
BIN
frontend/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/icons/icon-512.png
Normal file
BIN
frontend/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
9
frontend/icons/icon.svg
Normal file
9
frontend/icons/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#4285f4"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="102" y="154" width="307" height="266" rx="26"/>
|
||||
<line x1="102" y1="234" x2="409" y2="234"/>
|
||||
<line x1="170" y1="103" x2="170" y2="180"/>
|
||||
<line x1="342" y1="103" x2="342" y2="180"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |
@@ -2,10 +2,16 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<!-- APP_VERSION: update here + version.js on every release -->
|
||||
<title>Calendarr v2</title>
|
||||
<title>Calendarr v3</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#4285f4" />
|
||||
<meta name="apple-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="Calendarr" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" />
|
||||
<link rel="stylesheet" href="/static/css/app.css" />
|
||||
</head>
|
||||
@@ -71,7 +77,7 @@
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v2</button>
|
||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v3</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||
@@ -173,8 +179,9 @@
|
||||
<div id="cal-list-items"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v2</button>
|
||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v3</button>
|
||||
</aside>
|
||||
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
||||
|
||||
<!-- MAIN VIEW -->
|
||||
<main class="main-view" id="main-view">
|
||||
@@ -834,7 +841,7 @@
|
||||
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
||||
<span style="font-size:12px;color:var(--text-3)">Calendarr v2</span>
|
||||
<span style="font-size:12px;color:var(--text-3)">Calendarr v3</span>
|
||||
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,3 +188,12 @@ function loadAvatarImage(avatarEl, username) {
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────
|
||||
boot();
|
||||
|
||||
// ── Service Worker registration (PWA) ─────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
|
||||
console.warn('SW registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -820,7 +820,10 @@ function bindTopbar() {
|
||||
function bindSidebar() {
|
||||
document.getElementById('sidebar-toggle').onclick = () => {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
document.body.classList.toggle('sidebar-open'); // mobile slide-in
|
||||
};
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
if (backdrop) backdrop.onclick = () => document.body.classList.remove('sidebar-open');
|
||||
|
||||
// Add calendar dropdown
|
||||
const addBtn = document.getElementById('btn-add-cal');
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v2';
|
||||
export const APP_VERSION = 'v3';
|
||||
|
||||
30
frontend/manifest.json
Normal file
30
frontend/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Calendarr",
|
||||
"short_name": "Calendarr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0e0e14",
|
||||
"theme_color": "#4285f4",
|
||||
"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.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
86
frontend/sw.js
Normal file
86
frontend/sw.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Calendarr Service Worker
|
||||
// Cache-first for static assets, network-first for /api/* (graceful offline)
|
||||
|
||||
const CACHE_VERSION = 'calendarr-v3';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/static/css/app.css',
|
||||
'/static/favicon.svg',
|
||||
'/static/js/app.js',
|
||||
'/static/js/api.js',
|
||||
'/static/js/calendar.js',
|
||||
'/static/js/color-picker.js',
|
||||
'/static/js/date-picker.js',
|
||||
'/static/js/i18n.js',
|
||||
'/static/js/utils.js',
|
||||
'/static/js/version.js',
|
||||
'/static/js/views/agenda.js',
|
||||
'/static/js/views/month.js',
|
||||
'/static/js/views/quarter.js',
|
||||
'/static/js/views/week.js',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon.svg',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_VERSION).then(cache =>
|
||||
// Use addAll with a fallback so a single missing file doesn't abort install
|
||||
Promise.all(
|
||||
STATIC_ASSETS.map(url =>
|
||||
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
||||
)
|
||||
)
|
||||
).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Network-first for API routes — fail silently if offline
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(req).catch(() =>
|
||||
new Response(JSON.stringify({ offline: true }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for everything else (static)
|
||||
event.respondWith(
|
||||
caches.match(req).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(req).then(resp => {
|
||||
// Only cache successful, basic-origin responses
|
||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||
}
|
||||
return resp;
|
||||
}).catch(() => {
|
||||
// Offline fallback for navigation requests
|
||||
if (req.mode === 'navigate') return caches.match('/index.html');
|
||||
return new Response('', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user