From 6c7c8a4662621f5a17a764dab5d0707a1b14ece9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 10:35:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20PWA-Unterst=C3=BCtzung=20und=20Mobile-R?= =?UTF-8?q?esponsiveness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/main.py | 23 ++++++ frontend/css/app.css | 143 ++++++++++++++++++++++++++++++++++++ frontend/icons/icon-192.png | Bin 0 -> 1895 bytes frontend/icons/icon-512.png | Bin 0 -> 6002 bytes frontend/icons/icon.svg | 9 +++ frontend/index.html | 17 +++-- frontend/js/app.js | 9 +++ frontend/js/calendar.js | 3 + frontend/js/version.js | 2 +- frontend/manifest.json | 30 ++++++++ frontend/sw.js | 86 ++++++++++++++++++++++ 11 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 frontend/icons/icon-192.png create mode 100644 frontend/icons/icon-512.png create mode 100644 frontend/icons/icon.svg create mode 100644 frontend/manifest.json create mode 100644 frontend/sw.js 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 0000000000000000000000000000000000000000..d0beaf694311ed9b81d704fec2c0fb33e0a377c3 GIT binary patch literal 1895 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zcaljKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1qt-3~YZqT^vIy;@;lb+Z|Faa;)(GwvaF|$P9{% z^z71A5o2S~72=4J5ZKZouz^F#MPSPfCzk+$q=hO+?#`{<|ByrY$(`Nj^6%Jww${JT zZs&VLd8Xy}nLEFqdpGCTv){dpD_%|c$M2vj!mu=uVTv1LfE24kCT%%Wbq^f9z4_t8 zU-v$|`TFm}i=+Pve!lzmBHd{9zaP)N#2#F?FK=IYqH#gJ+2U9I|BIh;DXcGvKh?kg zeNURv%6~t$7O_qU->Luf>F(x>K5AFx_lK=xkWX)~dcOF@OrKTub!%f7qZXwbWp>Ux z*~8@Haz5pfknLni))^i1PA*ZcQZwc<5VW1_>HWngO(a37N^SDA7c0gD-gf&48%1Kb$Z$TzT6`#|gL=)>ph-|+dzy{-RMyf1Yt#8H>Mz7@SZ zm$SwA{2`HqxBtFqg z)g52fRTqadSU!KgMLqvXTHC!!|94ymSo7bVkX^Jn{O=vvqELpPL28ri=k04e7s~i# zTV(c=njpK9pYNtW&D#uh>}S>I>vHRE9o)P8^xW;I=KR}URS?WjDHN@gzwr3Z?RS7d z3<}cK`SrJ??(NC9ZvFevT>d&!9#_E?@oleUo<9%=1uif!bryeBYny&q!JKu%?<3ts zLfbDwBP%lObZ*Yitzx#Y93q_0!wX)VVHkJKYZ&R+t)LF z!oL1kT*JA3Hq(|r%a+gkDb-!4dgFBT=Bc5!|3cq>-f>Da;o{GgF+C8K;akr?)&hou zz3r>ZX+P^NIxp*-2O0nsR%Ac8_W17q@#QCeuBw?(9CZ47o@jsl)ZaFJlUN(}YW!Zp zU?7+}UxYzz(VNLk2VADq1~JU&*yPW}ARPG3lQBW*l$|DnkIPMU)`m_L#l|6w9_wBt zFJR|#*ySqEY`a7A!2Q`G3!=UlUI2RZ%&mPQO)6b>Y{1}bV7|w23Zlf}w0!pDn2X09 z`180vinfLmjFXOzu_pYBmuK~<$JfakfB#Z!a_ZbYgE_m8Wll&>UzcMx_uRgka)ViKfqJjK%YXem z!|2nILfr?GW&ZTX>C~xDKfyh3-g}_rlarIfe@r{UdhO}%+l%X&|HkaG&EC~F+r2LC zzcetFG-T9%{bBQ|O#3?ji{!t#za8!Sr(c$TxQd~u?tanlo+EwJ!|Q>(MPH*Dj`U0~ z{%={%D&QZdTd(Ev$9kZ;6f>~s4RM20E0j%K=@+-BKkmQV=iVQeJkOVJX3m*+&YW+~ znR8B>|5hI(S(r-%W1tSIcYy`KPY-LbqRYy#a8GD! z&b}}}N6aw&4^CT~??bku96``6;eV}ejkm-?MRk!76Rxof3sQ#9oXd?6pL+YNa}V&f|d##ugv}jdiQe z$fFr$TcT@@jwD|*N6co&m2pEZ7u)8f~0hS#snPk6gr1#ZI(bD+^x^ElUKgjo(s; zd7=}gA3(BnZPZ#e!Wt6OJWt`=y39o$bj}mPpCSF9X@ib%S9X`Sm~+?#p^C1i=3bml0v7-+4%4Fptf;}~EGJbkb z=$9xIAA{yqCniJYQS4L5C+`>g=fj^pnkv!`y7f)wa~ZyPo@%H=y?jPS(C!F z(P)&3G?mxYXhFUWTHh`e9_uhPVVN~#9dm@@_14OQe)27bW*W3ax&bCPUd)Ea0x4;Y zY1W1Aw3O*>AmOXRH^U=5)ZOm{=z?~WJF5`k(bDpJUih62Q%h>eOS64ot<8|58aobQ zxKjO+phYv=rpPTV-I~T!Y2br1)kBXmK7Mj%mOD+ZF;4okQQmUEUh;BW>=S~hD~6^~ zv|qaEjz~rRA)NyW_KLzDAV`gBlTMyfe-XKBZh{i0IvUw!tG zb>K6>h5ERAtCttWGW)JHXcs&pg6s?rL6CK)ORmTxyRIR+b|>V5T(hG)HxR1kcZuO7 zC-5XRX;t4S?Q~~H=0^dGtZBJx{c5Ie9Kq(VN8;{|EV630M#~%e?1&M)W#J_d(1PX) z;%ZlE--$j_S`k=0pB>{>-anG3r3?`vp=aMe+d%o9n1Qxz-tb3beSs556U=sVDcZ%f zN$e%DbpG(p4hSl9nN;{xfH9C)KJo`<8KT#VNMre$sE=;)Xhid|gA{o<_PiAmccni} z(>9QTEP=)U?(Lr;S+dBk5QvEs#oE@#}TBVX#0w~1EW7_x?fP90d?NP zilgn4uQ!Rc)Om`pPeBf@eqh`a+5+kf{M@Ri3@FUl0O`s>`c9dF*>g6h-9ce3H< z>S|OE29oikQ%*fU4PU?Ua9}*_<2vGuCF?J~y2#v8HkW-GhY96q_k55_`|{hL_><*k z0&&?-tG4}XB>A4m75Z4#xg{BHS7-y~=0ZX?Z%p&%c@FXBq1WP)k1Kt(*E936YscgW!!7%x>qAb)OyK&&p$p1y@eIAo>39^9E3`uCb3_S7&K@lUvJ zX+MI+0RWG`-SnTgm3*2bK%oL7I|_8RrH2&~i? zpkdI|3-5V*Hq#;2==ZEnx@rGF+iMc6s)LX`Y?y9r4xjuz~fbQ<`!{aw)3g>r`)a)85 zr!d6T#Nk(awk9srQ+(-th^5P2RIdE{?&H0RYjS{MF3%(?+E^YTuJ6J#0`Gwv|98%c zRouks=VV}7n4~@kO}-ez<0s1A#b_OB)gEeTSTJ^4Iiv&76$i+Okl?|)?ofCah-KJZ zGVPoNT<{&Du~(Kz+&t)>gQ^rF&iH{07L2}+RZTe%iVmh*M? z`pOs=zuJ8rHOT_uDWAHj+}gEYk2(Q2sD{#-3QuEfe*&e7r2Rf-1Sl~g-i9+6gL4V1 zB`o-zqJAH}bcQX4ck>r}T^5zY!Q*!_6SdO;P?p6zya;u7Pa(CfKzl3z7&}NuYEUSj zvqH@f0k)nV_j!X+3hJ((K&){ekD(!JJl#iCjnnMZG)8<=(geNJX@fqZ3nws#vqr;> zO|uwD-JNlcz(D?6exIDnVEn=`(AcKKV{uY)I|@}WI@D*Gldu3<*x2v$Iaw=5gxXvS zx}FXDLgi8%ewi>*D}_jh+gG^pTo$1MoTQEEHk! + + + + + + + + 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 }); + }); + }) + ); +});