diff --git a/backend/main.py b/backend/main.py index 46ccbde..9c122f4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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/"): diff --git a/frontend/css/app.css b/frontend/css/app.css index 39f383e..bbca5fb 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -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); } +} + diff --git a/frontend/icons/icon-192.png b/frontend/icons/icon-192.png new file mode 100644 index 0000000..d0beaf6 Binary files /dev/null and b/frontend/icons/icon-192.png differ diff --git a/frontend/icons/icon-512.png b/frontend/icons/icon-512.png new file mode 100644 index 0000000..d7cc4e4 Binary files /dev/null and b/frontend/icons/icon-512.png differ diff --git a/frontend/icons/icon.svg b/frontend/icons/icon.svg new file mode 100644 index 0000000..0401ec2 --- /dev/null +++ b/frontend/icons/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html index 40795b9..69bce84 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,10 +2,16 @@ - + - Calendarr v2 + Calendarr v3 + + + + + + @@ -71,7 +77,7 @@ - + @@ -173,8 +179,9 @@
- + +
@@ -834,7 +841,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/app.js b/frontend/js/app.js index b99f093..c33fed0 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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); + }); + }); +} diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 7b3b41b..1135044 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -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'); diff --git a/frontend/js/version.js b/frontend/js/version.js index 9d3baa6..b54d089 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v2'; +export const APP_VERSION = 'v3'; diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..496cdbb --- /dev/null +++ b/frontend/manifest.json @@ -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" + } + ] +} diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..81d23ad --- /dev/null +++ b/frontend/sw.js @@ -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 }); + }); + }) + ); +});