diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc index f4b4dc3..5ae5f35 100644 Binary files a/backend/__pycache__/app.cpython-312.pyc and b/backend/__pycache__/app.cpython-312.pyc differ 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') }} - + +