From 8152072beca99e2022547e006e5b53e4e5891547 Mon Sep 17 00:00:00 2001 From: Dominic Date: Tue, 3 Mar 2026 17:38:51 +0000 Subject: [PATCH] feat: Clock --- backend/__pycache__/app.cpython-312.pyc | Bin 38152 -> 41388 bytes backend/app.py | 54 +++++++++ backend/static/countdown.js | 109 ++++++++++++++++++ backend/static/dashboard-transition.js | 106 ++++------------- backend/static/styles.css | 147 ++++++++++++++++++++---- backend/templates/base.html | 61 ++++++++-- docker-compose.yml | 2 + 7 files changed, 365 insertions(+), 114 deletions(-) create mode 100644 backend/static/countdown.js diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc index f4b4dc30ea3213635f7ed78ee66f0f24f4c0cd74..5ae5f356537c1e0237e61acbe73846d8fbcb5194 100644 GIT binary patch delta 12704 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 DK%x`1 delta 9860 zcmbVQ33wF8mF^x%BMqYq-S-7T8UYfQxWpwP5E4QXNL+)>c%*7c1EU#HGvW}~12H}@ zJ|uTyqlDOwO|t96o3&T*MY4`J-s{`VmroOC6RRC(^X1rYH=Cm|PBw88Xa83{(=!5M z_S=Deul`r>y?*to>Q%K~c+2wO7cI5_nwBP1zYXHt$OoL#7lRX(3suZ36@!XH{v( z+pCl^mO5^iq-YE``LW;DPg=xJ`p5aHOd~(gT<~!*C1;Q?mZ^Fzg$dD-%)ea(8Cfj* zrns|iYn8)tZ?ej{tyLb&zsV}^wpInK@FuH*+gcT|;+w3BZfBKN5}nKFS_qYp&9KBu zpOc*n!OOs_ha^_cDp)0}Vhe^WY~kg^Ntso%MXZKZ56KZ+%N)S9Cazz|FjsEKWWP7~W`>TF`0h0YdJrU?Fe z&CvF-{g51B2Th5S9fHJZ+T6kJ1iphECXJ9tt&80Sk(>3KMgxpNGGH1hY!G;e4VxMs zb_5cSsd1Effk&9n)bO)WNc^V8F~)(9nK;0L7(WY`f-!bnr0j&5kCW^aOir2_ z8Fsh%l6zPPl6y>zv+Nx3SrdPP-3xr4-Dg_d&mMr}L3V*8+r{`FVizHLm_1@DJ<1*v zp-eNQT6YNPv!SAt&!Uj_a(@J8@Az*mF63BE>^L+l%9tcBq3!PkNRBlvpoe+F*?{}=EL z;NJw_2>vbbP2e*u4Ek;MHt2WQzftxm|LENe$vfa%z%}q@@b81Sfd2q|EBFt=Tfsv= zLSq{mKLMw-{0I1U@c#sF1OFNL4)C9Yw}bx@yaQYZ-wFOccqjM=;Jd(o1Kt%0=C^2f zL-rwf5BMLzcY}WfzDG>E6hgCCG%aZLiB=+7dqvBN);`fnL2JKg%|q*eXr-ccP_)v~ za*9?aT8Bg{3#~guD+jGRMJq45+91KCV7+1f4+0#mr!6jsP#lnQ*|kDG*h!&bMzIRF(=a zN6#mIDbnSv7k2WI!!g}xOXzyxYZ-j?t1r&aE&M#RN8+qk0Q(eJQ*X#0iE5=%RR|}8 zl-5z$uL38_V`w)<+8piGXa~+q&Nbjc@U?hPAo}(gSF)q$9Q4M{OJ?lrfsaSt*)*6) z^K=4kH^5CZ1yp)6&hS*6;YL$qJWk_uoW>?-goKYXaT<5WX>2hy?upX~#c4E0HLB0X zX`G8pBW17Y~mJ{J50;T zkFk9G7MAU1eCnL|K6wk>4(N*cYxvY0-6xaTq3?6}d?->M%}!pt zIwof_{fo{l^yqZ^+|gP|;`y+7j@E$fS;(HlJY7+LkX<8xLAB=8O!wqCEYsi5vnI$p zrhi=ckt{b&|EPGaY;VC9{Pp>Ya{8rPUo6?)SSn8%u_U zbBw?BwSl7!yEZQnbO*-*u2V`tvqjoIzh;j#{iBLc%kwHjZm)|eF85e)*v~yM@f=sQ z6c&j?vogi21QqoMwMA>S>_IPH=JJfVhZGo52h%bQp_`3(d@jGwdrGshXh>ROzkA^5 z^hCD%TP-gQZV$sZ3p^#9WuWLQGh=6Dn3jn8^y;O+@quQqo=eK zD7w6E-w^*b`bX6rE2bRR(Grlqj~49z2h6bNAE2qpLH;}NF?HU`YRhk=>E$a+WVu4^ zS~Y5UUsB&(wYNg3<&f`Th5I9eat8uRkS8OMQ)*-5`QqQe#{eGlmxWdWp6geq-)x$a z6PZ1jq53zL_NMnrj`S|R5`BC%2*T$6u~C>AJr_Z-;o-PN?q1m zA=}j5&3SU6I^0~7C1$}&v%vcK0=)Za^>fV(Go~iD9B(Ov0PDpDr&_|Oe^g5v@cILa z!=hPT3g>>#Nn_5CQiKM1t3}uI>epN9$=k15e!f{t2n4w!kvq`A>p-+b?mrRG5{M>> zw3vwry8FEf^5PvE@dfxwD8D#e)cRFRvKS!GpZ@;#(@FA5^+4y2j#$Qv$a4viQX<7f z%7~N`sV73NG%F?C6Lci;Dgjx)i+MPAs6Xqh$rdR|_V_#^C!3(~^7O)8hZ5u!>e-&Y z%;gBwn9fzyTB`n}=YA_?j@PTByUW*VHc!Am=;tHupjOzrZF}?H&OTRr*S_|?=Dzms zE?3*0?!7&|oEEZX!+s88^jM^l`ggmlQ`WwkUq{O!Yt|9>N!Lifme|wX+sE%ConB}=Qndv1@D{R+tsE+2 z+zei`8WYGVUT_M2a^Ifp(+VlE>6iR!Eb&mZc^x{sxI@fAF`6`~#7H0zq;`SDaM!50@UKx`s`C z!|XKB3&q4x+^`k1A#OGS7it(6W70PUUZ5~-k?aZJPh_4Tmq@gO2-SSrqa~8VZX`8xU<^yj7j&6dm{sbYV-3~` zdfjUEfn&89R1b`?(tw|te&blZg(m!$ff4I!%88aVI_wYn`PWEMRKLHamKc8?T75j9 z-aAM{WXh&aj(3ONKo^Q<(tv-=7vv8Dji>D$r7Lm8pc^%c@i$3k9}_YoPY$u?MIN*y zDr5mI88-?qE+FDYb<4d^wN6VJ@u0Sz8ddl(!K~&jot@qLx3#)O0u8tv9Yl z7g4O@OczTt0fF$XBx@y-O>jtk`gGo^N2y8I04?!|-{UiK9h<*c*2R=0coiSdRR8OA zRi;?kc1<4Sm&oOlYVnz3E3I?hp>8;{DN|%!B$LRymg$GiOee06Z4|L6gcFOHN&W(C zV+REMP(}sGPy^>$XvSVWw?3gth(%E~4_GadV;m0sr}#T+_I-;&G;u~sXVG$G-MB|% zXKrk?H!MoA+C(PBJ?Uv0oOujtR!@K?P_uGn#D82t75FPsFbc0{BYk-AM@f;4=idI`)Duu z715^AFwLNF+S0w8zX}qcOD#2^@Z*ZhSc?30bT_Ha2MWtewzCnL3nUHAn zPbmE8xH2|WA5c(hJi$}O;hROB>1O9x(Bln=jisfyTt4@R;&NeL)Oin=h5ksY$%^l| zhvUmdOxvI6WwG?o{HS*AhqlC|u%3agKtdb6XtAV)bZA_^b>nA#tLK z1Ij4FqAG}OOp9HtMzLnZG7vMV*%2>3X~bah(KF_|;r#wEWy6S{jd_(#T!Rn%mjZ9$ zQluxyviz=Odsj+-SF*n+mA)qxC%h*WOuzWZF?l-lSa)KmN48tvNy(U*zd%o*_779C zW^&f)Nz~S@k}Y>8Z-<^NP>N*BoXFR00?b3##E5PeAXTyz&J-=y(*#PFZ22<|&ley|vgJ*L^lSlgB-{MZM4g^1P@ZJVn6T>k0u-QYqE;^yph)=JsTT`WBH6Mg z*6F1Jlu5RnncNz^T%Zbak*rq=P({FgZGiv_p*L|zuNGjDWGk2{tkY|NLRP&N@KTxX z5PfwN?U24$fF+U*JFi1uDp0*-E1fCp(i;R?2KO_WmHKjlR*1eneWgIFL^>MvMuApK zwvw4rpT0(*wUVu3rgEdcPN4N6F}8ZWNx%&vGKaoVpiPo3dt!&aS%59Fk&rcM}g0f3z^gE6I!-oAD-DTjrfRVywy4%1wd~KC8Rh#tz17kaYC`&b z?++OmR|L{;)jbBr4TA7N{b=Otn27Sa(!0njkdW-HC_}VCNS-8AiKW1RuwQbem zMcsP9z(L^3aMdY&%)q#{k$$&+!oVkii>EsEQwAId%%958Ps=?aY_n@;fD5Jy^}CJk zdqlTQH|FNr77HeGYKh)#p_#wd0$4n?L*Ht^R=|=er@qaA+X-IUr?(k!2iab#xz=vL z4vUmuHk(^D3tDw(Hm7VhlvOgjaOZ6Kve}Z(+1!fR%);v~p{w@`c0*|h=PZjW4dJ}z zKUW$iy>X=>TvQiV8o~vRSZN65LNityCgW?vWbOZ{G=vM*$CZZ3_|hr3ag||GiK`6Z;;nI&AzaoSR~aVL;|fE#Yt-x}hxa8_Gg+Tv-TVFL%tX3+YrBVr8LtZdsT&w=6j3mW8&tW#Pcbm4&Hll!f); zO;i`EW^)(L=2y&Cxv4H>70oW}o-J>fEol{HAum!E-d8LG7Mta4(zQajVL9)bB}=wW1g}eA*Apa5zD|N`b<>NU8c#_5QEtjWLc)&|k_J+f{}-BIC*S}8 diff --git a/backend/app.py b/backend/app.py index 11168a2..967c5f5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4,6 +4,7 @@ import uuid from datetime import datetime from functools import wraps from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from flask import ( Flask, @@ -34,6 +35,9 @@ app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get( "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d0", ) app.config["WEDDING_DATE"] = os.environ.get("WEDDING_DATE", "") +app.config["WEDDING_COUNTDOWN_ISO"] = os.environ.get("WEDDING_COUNTDOWN_ISO", "") +app.config["WEDDING_COUNTDOWN_LOCAL"] = os.environ.get("WEDDING_COUNTDOWN_LOCAL", "2026-09-04 15:00") +app.config["WEDDING_TIMEZONE"] = os.environ.get("WEDDING_TIMEZONE", "Europe/Berlin") app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME") ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "heic", "heif"} @@ -280,6 +284,14 @@ TEXTS = { "flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.", "dashboard": "Dashboard", "back": "Zurück", + "countdown_button_label": "Countdown bis zur Hochzeit", + "countdown_until": "Noch", + "countdown_started": "Die Feier hat begonnen", + "countdown_days": "Tage", + "countdown_hours": "Std", + "countdown_minutes": "Min", + "countdown_seconds": "Sek", + "countdown_subline": "bis zur Hochzeit", }, "en": { "brand": "Svenja & Dominic", @@ -361,6 +373,14 @@ TEXTS = { "flash_admin_only": "This area is available to admins only.", "dashboard": "Dashboard", "back": "Back", + "countdown_button_label": "Wedding countdown", + "countdown_until": "Starts in", + "countdown_started": "The celebration has started", + "countdown_days": "Days", + "countdown_hours": "Hrs", + "countdown_minutes": "Min", + "countdown_seconds": "Sec", + "countdown_subline": "until the wedding", }, } @@ -390,6 +410,39 @@ def get_hero_image_asset() -> str: return "assets/hero.jpg" +def get_wedding_countdown_iso() -> str: + configured_iso = str(app.config.get("WEDDING_COUNTDOWN_ISO", "") or "").strip() + if configured_iso: + try: + datetime.fromisoformat(configured_iso.replace("Z", "+00:00")) + return configured_iso + except ValueError: + pass + + local_value = str(app.config.get("WEDDING_COUNTDOWN_LOCAL", "") or "").strip() + timezone_name = str(app.config.get("WEDDING_TIMEZONE", "Europe/Berlin") or "Europe/Berlin").strip() + if not local_value: + return "2026-09-04T15:00:00+02:00" + + parsed_local = None + for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + parsed_local = datetime.strptime(local_value, fmt) + break + except ValueError: + continue + + if parsed_local is None: + return "2026-09-04T15:00:00+02:00" + + try: + tz = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError: + tz = ZoneInfo("Europe/Berlin") + + return parsed_local.replace(tzinfo=tz).isoformat() + + @app.context_processor def inject_common() -> dict: role = session.get("role", "guest") @@ -408,6 +461,7 @@ def inject_common() -> dict: "location_website_url": app.config["LOCATION_WEBSITE_URL"], "google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"], "wedding_date": app.config["WEDDING_DATE"], + "wedding_countdown_iso": get_wedding_countdown_iso(), "hero_image_url": url_for("static", filename=get_hero_image_asset()), } diff --git a/backend/static/countdown.js b/backend/static/countdown.js new file mode 100644 index 0000000..6f3a4a5 --- /dev/null +++ b/backend/static/countdown.js @@ -0,0 +1,109 @@ +(() => { + function pad2(value) { + return String(value).padStart(2, "0"); + } + + function splitCountdown(ms) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return { days, hours, minutes, seconds }; + } + + function setAnimatedValue(node, nextValue) { + if (!node) { + return; + } + if (node.textContent === nextValue) { + return; + } + node.textContent = nextValue; + node.classList.remove("is-updated"); + void node.offsetWidth; + node.classList.add("is-updated"); + } + + function initTimer(widget) { + const targetIso = widget.dataset.countdownTarget; + const startedLabel = widget.dataset.countdownStarted || ""; + const toggle = widget.querySelector("[data-countdown-toggle]"); + const popover = widget.querySelector("[data-countdown-popover]"); + const subline = widget.querySelector("[data-countdown-subline]"); + const daysNode = widget.querySelector("[data-countdown-days]"); + const hoursNode = widget.querySelector("[data-countdown-hours]"); + const minutesNode = widget.querySelector("[data-countdown-minutes]"); + const secondsNode = widget.querySelector("[data-countdown-seconds]"); + + if (!targetIso || !toggle || !popover || !subline || !daysNode || !hoursNode || !minutesNode || !secondsNode) { + return; + } + + const targetMs = Date.parse(targetIso); + if (Number.isNaN(targetMs)) { + subline.textContent = "--"; + return; + } + + const update = () => { + const now = Date.now(); + const delta = targetMs - now; + + if (delta <= 0) { + setAnimatedValue(daysNode, "0"); + setAnimatedValue(hoursNode, "00"); + setAnimatedValue(minutesNode, "00"); + setAnimatedValue(secondsNode, "00"); + subline.textContent = startedLabel; + return; + } + + const parts = splitCountdown(delta); + setAnimatedValue(daysNode, String(parts.days)); + setAnimatedValue(hoursNode, pad2(parts.hours)); + setAnimatedValue(minutesNode, pad2(parts.minutes)); + setAnimatedValue(secondsNode, pad2(parts.seconds)); + }; + + const close = () => { + popover.hidden = true; + toggle.setAttribute("aria-expanded", "false"); + }; + + const open = () => { + popover.hidden = false; + toggle.setAttribute("aria-expanded", "true"); + update(); + }; + + toggle.addEventListener("click", (event) => { + event.preventDefault(); + if (popover.hidden) { + open(); + } else { + close(); + } + }); + + document.addEventListener("click", (event) => { + if (!widget.contains(event.target)) { + close(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + close(); + } + }); + + update(); + window.setInterval(update, 1000); + } + + document.addEventListener("DOMContentLoaded", () => { + const widgets = document.querySelectorAll(".toolbar-timer"); + widgets.forEach(initTimer); + }); +})(); diff --git a/backend/static/dashboard-transition.js b/backend/static/dashboard-transition.js index df82879..2cd2fb1 100644 --- a/backend/static/dashboard-transition.js +++ b/backend/static/dashboard-transition.js @@ -1,87 +1,31 @@ (() => { - const OPENING_MS = 420; - - document.addEventListener("DOMContentLoaded", () => { + function setupDashboardAnimations() { const dashboardGrid = document.querySelector(".dashboard-grid"); - const dashboardLinks = document.querySelectorAll(".dashboard-link-card[href]"); - - if (dashboardGrid) { - dashboardLinks.forEach((link, index) => { - link.style.setProperty("--stagger-delay", `${index * 55}ms`); - }); - - dashboardGrid.classList.add("is-ready"); - window.requestAnimationFrame(() => { - dashboardGrid.classList.add("is-animated"); - }); + if (!dashboardGrid) { + return; } - dashboardLinks.forEach((link) => { - link.addEventListener("click", (event) => { - if (link.classList.contains("is-opening")) { - event.preventDefault(); - return; - } - - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - - const href = link.getAttribute("href"); - if (!href || href.startsWith("http")) { - return; - } - - event.preventDefault(); - if (dashboardGrid) { - dashboardGrid.classList.add("is-focusing"); - } - link.classList.add("is-opening"); - - // Force layout before running the exit animation for consistent playback. - void link.offsetWidth; - - let hasNavigated = false; - const navigate = () => { - if (hasNavigated) { - return; - } - hasNavigated = true; - window.location.href = href; - }; - - if (typeof link.animate === "function") { - const animation = link.animate( - [ - { opacity: 1, transform: "translateY(-6px) scale(1)", filter: "blur(0px)" }, - { opacity: 0, transform: "translateY(-42px) scale(0.982)", filter: "blur(2px)" }, - ], - { - duration: OPENING_MS, - easing: "cubic-bezier(0.22, 0.61, 0.36, 1)", - fill: "forwards", - } - ); - animation.finished.then(navigate).catch(navigate); - } else { - // Fallback if WAAPI is unavailable. - link.style.transition = `transform ${OPENING_MS}ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity ${OPENING_MS}ms cubic-bezier(0.22, 0.61, 0.36, 1), filter ${OPENING_MS}ms cubic-bezier(0.22, 0.61, 0.36, 1)`; - requestAnimationFrame(() => { - link.style.transform = "translateY(-42px) scale(0.982)"; - link.style.opacity = "0"; - link.style.filter = "blur(2px)"; - }); - } - - window.setTimeout(navigate, OPENING_MS + 240); - }); + const dashboardLinks = document.querySelectorAll(".dashboard-link-card[href]"); + dashboardLinks.forEach((link, index) => { + link.style.setProperty("--stagger-delay", `${index * 45}ms`); }); - }); + + dashboardGrid.classList.remove("is-ready", "is-animated", "is-focusing"); + dashboardLinks.forEach((link) => { + link.classList.remove("is-opening"); + link.style.removeProperty("transition"); + link.style.removeProperty("transform"); + link.style.removeProperty("opacity"); + link.style.removeProperty("filter"); + link.style.removeProperty("pointer-events"); + }); + + dashboardGrid.classList.add("is-ready"); + window.requestAnimationFrame(() => { + dashboardGrid.classList.add("is-animated"); + }); + } + + document.addEventListener("DOMContentLoaded", setupDashboardAnimations); + window.addEventListener("pageshow", setupDashboardAnimations); })(); diff --git a/backend/static/styles.css b/backend/static/styles.css index 366cca6..c335e47 100644 --- a/backend/static/styles.css +++ b/backend/static/styles.css @@ -57,6 +57,7 @@ h3 { gap: 0.4rem; flex-wrap: wrap; justify-content: flex-end; + align-items: center; } .container { @@ -103,7 +104,7 @@ h3 { background: var(--card); border: 1px solid rgba(39, 66, 53, 0.1); border-radius: 18px; - box-shadow: 0 10px 32px rgba(39, 66, 53, 0.08); + box-shadow: 0 8px 22px rgba(39, 66, 53, 0.09); padding: 1.25rem; margin-bottom: 1rem; } @@ -127,6 +128,7 @@ h3 { align-items: center; justify-content: center; min-height: 90px; + cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease; } @@ -145,12 +147,6 @@ h3 { animation-delay: var(--stagger-delay, 0ms); } -.dashboard-grid.is-focusing .dashboard-link-card:not(.is-opening) { - opacity: 0.56; - transform: scale(0.975); - filter: saturate(0.82); -} - @keyframes dashboard-card-in { from { opacity: 0; @@ -173,28 +169,18 @@ h3 { background: #fffdf9; border: 1px solid rgba(39, 66, 53, 0.11); letter-spacing: 0.01em; - box-shadow: 0 12px 30px rgba(39, 66, 53, 0.12); + box-shadow: 0 8px 22px rgba(39, 66, 53, 0.09); will-change: transform, opacity, filter; } -.dashboard-link-card:hover { - transform: translateY(-6px); - box-shadow: 0 18px 40px rgba(39, 66, 53, 0.18); -} - -.dashboard-link-card.is-opening { - box-shadow: 0 20px 44px rgba(39, 66, 53, 0.22); - pointer-events: none; -} - .link-card:hover { - transform: translateY(-3px); - box-shadow: 0 14px 30px rgba(39, 66, 53, 0.16); + transform: translateY(-5px); + box-shadow: 0 14px 30px rgba(39, 66, 53, 0.15); } -.dashboard-grid .dashboard-link-card:hover { - transform: translateY(-6px); - box-shadow: 0 18px 40px rgba(39, 66, 53, 0.18); +.link-card:focus-visible { + transform: translateY(-5px); + box-shadow: 0 14px 30px rgba(39, 66, 53, 0.15); } .form-card { @@ -448,10 +434,13 @@ input[type="file"]:focus { padding: 0.42rem 0.78rem; font-size: 0.98rem; font-weight: 600; + min-height: 2.35rem; } .toolbar-nav-btn { - padding: 0.42rem 0.62rem; + width: 2.35rem; + padding: 0; + flex: 0 0 2.35rem; } .toolbar-nav-btn svg { @@ -460,6 +449,106 @@ input[type="file"]:focus { fill: currentColor; } +.toolbar-session-actions { + display: inline-flex; + align-items: center; + gap: 0.38rem; + flex-wrap: nowrap; +} + +.toolbar-timer { + position: relative; +} + +.toolbar-timer-btn { + width: 2.35rem; + padding: 0; + flex: 0 0 2.35rem; +} + +.toolbar-timer-btn svg { + width: 0.9rem; + height: 0.9rem; + fill: currentColor; +} + +.toolbar-timer-popover { + position: absolute; + top: calc(100% + 0.38rem); + right: 0; + min-width: 12.4rem; + padding: 0.62rem 0.7rem; + border-radius: 12px; + border: 1px solid rgba(39, 66, 53, 0.14); + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 10px 26px rgba(39, 66, 53, 0.14); + z-index: 25; +} + +.toolbar-timer-label { + margin: 0 0 0.35rem; + color: rgba(31, 31, 31, 0.72); + font-size: 0.76rem; +} + +.toolbar-timer-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.38rem; +} + +.toolbar-timer-unit { + background: rgba(39, 66, 53, 0.045); + border: 1px solid rgba(39, 66, 53, 0.09); + border-radius: 10px; + padding: 0.38rem 0.18rem 0.3rem; + display: grid; + justify-items: center; +} + +.toolbar-timer-value { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--forest); + line-height: 1.05; + letter-spacing: 0.01em; + font-variant-numeric: tabular-nums; +} + +.toolbar-timer-value.is-updated { + animation: countdown-pop 220ms ease; +} + +.toolbar-timer-unit-label { + margin-top: 0.1rem; + color: rgba(31, 31, 31, 0.62); + font-size: 0.64rem; + font-weight: 600; + letter-spacing: 0.03em; +} + +.toolbar-timer-subline { + margin: 0.42rem 0 0; + color: rgba(31, 31, 31, 0.74); + font-size: 0.74rem; +} + +@keyframes countdown-pop { + 0% { + transform: translateY(0); + opacity: 1; + } + 35% { + transform: translateY(-2px); + opacity: 0.75; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + .flash { padding: 0.7rem 0.9rem; border-radius: 10px; @@ -780,6 +869,16 @@ input[type="file"]:focus { } @media (max-width: 640px) { + .toolbar-timer-popover { + right: -0.1rem; + min-width: 11.5rem; + padding: 0.5rem 0.6rem; + } + + .toolbar-timer-value { + font-size: 0.9rem; + } + .location-actions { justify-content: flex-start; } diff --git a/backend/templates/base.html b/backend/templates/base.html index 0e425eb..6f979dd 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -22,14 +22,56 @@ {% if guest_name %} - - - -
- -
+
+ + +
+
+ + + +
+ +
+
{% endif %} @@ -51,6 +93,7 @@ {{ t('privacy') }} {{ t('imprint') }} - + + diff --git a/docker-compose.yml b/docker-compose.yml index 99fab22..1b5a496 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,8 @@ services: - EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026} - HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026} - WEDDING_DATE=${WEDDING_DATE:-} + - WEDDING_COUNTDOWN_LOCAL=${WEDDING_COUNTDOWN_LOCAL:-2026-09-04 15:00} + - WEDDING_TIMEZONE=${WEDDING_TIMEZONE:-Europe/Berlin} - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-268435456} - LOCATION_NAME=${LOCATION_NAME:-Klostermühle}