feat: Clock

This commit is contained in:
2026-03-03 17:38:51 +00:00
parent 225ac5e441
commit 8152072bec
7 changed files with 365 additions and 114 deletions

View File

@@ -4,6 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from flask import ( from flask import (
Flask, 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", "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d0",
) )
app.config["WEDDING_DATE"] = os.environ.get("WEDDING_DATE", "") 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") app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME")
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "heic", "heif"} ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "heic", "heif"}
@@ -280,6 +284,14 @@ TEXTS = {
"flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.", "flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"back": "Zurück", "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": { "en": {
"brand": "Svenja & Dominic", "brand": "Svenja & Dominic",
@@ -361,6 +373,14 @@ TEXTS = {
"flash_admin_only": "This area is available to admins only.", "flash_admin_only": "This area is available to admins only.",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"back": "Back", "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" 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 @app.context_processor
def inject_common() -> dict: def inject_common() -> dict:
role = session.get("role", "guest") role = session.get("role", "guest")
@@ -408,6 +461,7 @@ def inject_common() -> dict:
"location_website_url": app.config["LOCATION_WEBSITE_URL"], "location_website_url": app.config["LOCATION_WEBSITE_URL"],
"google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"], "google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"],
"wedding_date": app.config["WEDDING_DATE"], "wedding_date": app.config["WEDDING_DATE"],
"wedding_countdown_iso": get_wedding_countdown_iso(),
"hero_image_url": url_for("static", filename=get_hero_image_asset()), "hero_image_url": url_for("static", filename=get_hero_image_asset()),
} }

109
backend/static/countdown.js Normal file
View File

@@ -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);
});
})();

View File

@@ -1,87 +1,31 @@
(() => { (() => {
const OPENING_MS = 420; function setupDashboardAnimations() {
document.addEventListener("DOMContentLoaded", () => {
const dashboardGrid = document.querySelector(".dashboard-grid"); const dashboardGrid = document.querySelector(".dashboard-grid");
const dashboardLinks = document.querySelectorAll(".dashboard-link-card[href]"); if (!dashboardGrid) {
return;
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");
});
} }
dashboardLinks.forEach((link) => { const dashboardLinks = document.querySelectorAll(".dashboard-link-card[href]");
link.addEventListener("click", (event) => { dashboardLinks.forEach((link, index) => {
if (link.classList.contains("is-opening")) { link.style.setProperty("--stagger-delay", `${index * 45}ms`);
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);
});
}); });
});
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);
})(); })();

View File

@@ -57,6 +57,7 @@ h3 {
gap: 0.4rem; gap: 0.4rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
} }
.container { .container {
@@ -103,7 +104,7 @@ h3 {
background: var(--card); background: var(--card);
border: 1px solid rgba(39, 66, 53, 0.1); border: 1px solid rgba(39, 66, 53, 0.1);
border-radius: 18px; 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; padding: 1.25rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -127,6 +128,7 @@ h3 {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 90px; min-height: 90px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
@@ -145,12 +147,6 @@ h3 {
animation-delay: var(--stagger-delay, 0ms); 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 { @keyframes dashboard-card-in {
from { from {
opacity: 0; opacity: 0;
@@ -173,28 +169,18 @@ h3 {
background: #fffdf9; background: #fffdf9;
border: 1px solid rgba(39, 66, 53, 0.11); border: 1px solid rgba(39, 66, 53, 0.11);
letter-spacing: 0.01em; 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; 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 { .link-card:hover {
transform: translateY(-3px); transform: translateY(-5px);
box-shadow: 0 14px 30px rgba(39, 66, 53, 0.16); box-shadow: 0 14px 30px rgba(39, 66, 53, 0.15);
} }
.dashboard-grid .dashboard-link-card:hover { .link-card:focus-visible {
transform: translateY(-6px); transform: translateY(-5px);
box-shadow: 0 18px 40px rgba(39, 66, 53, 0.18); box-shadow: 0 14px 30px rgba(39, 66, 53, 0.15);
} }
.form-card { .form-card {
@@ -448,10 +434,13 @@ input[type="file"]:focus {
padding: 0.42rem 0.78rem; padding: 0.42rem 0.78rem;
font-size: 0.98rem; font-size: 0.98rem;
font-weight: 600; font-weight: 600;
min-height: 2.35rem;
} }
.toolbar-nav-btn { .toolbar-nav-btn {
padding: 0.42rem 0.62rem; width: 2.35rem;
padding: 0;
flex: 0 0 2.35rem;
} }
.toolbar-nav-btn svg { .toolbar-nav-btn svg {
@@ -460,6 +449,106 @@ input[type="file"]:focus {
fill: currentColor; 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 { .flash {
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
border-radius: 10px; border-radius: 10px;
@@ -780,6 +869,16 @@ input[type="file"]:focus {
} }
@media (max-width: 640px) { @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 { .location-actions {
justify-content: flex-start; justify-content: flex-start;
} }

View File

@@ -22,14 +22,56 @@
<button class="btn btn-ghost" type="submit">EN</button> <button class="btn btn-ghost" type="submit">EN</button>
</form> </form>
{% if guest_name %} {% if guest_name %}
<a class="btn btn-ghost toolbar-nav-btn" href="{{ url_for('guest_area') }}" aria-label="{{ t('dashboard') }}" title="{{ t('dashboard') }}"> <div
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> class="toolbar-timer"
<path d="M3 3h8v8H3V3zm10 0h8v5h-8V3zM3 13h5v8H3v-8zm7 0h11v8H10v-8z" /> data-countdown-target="{{ wedding_countdown_iso }}"
</svg> data-countdown-started="{{ t('countdown_started') }}"
</a> >
<form method="post" action="{{ url_for('logout') }}"> <button
<button class="btn btn-ghost" type="submit">{{ t('logout') }}</button> class="btn btn-ghost toolbar-nav-btn toolbar-timer-btn"
</form> type="button"
aria-label="{{ t('countdown_button_label') }}"
title="{{ t('countdown_button_label') }}"
data-countdown-toggle
aria-expanded="false"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M15 2H9v2h6V2zm-3 4a8 8 0 1 0 8 8 8 8 0 0 0-8-8zm3.4 11.4L11 13V8h2v4.2l3.8 3.8z" />
</svg>
</button>
<div class="toolbar-timer-popover" data-countdown-popover hidden>
<p class="toolbar-timer-label">{{ t('countdown_until') }}</p>
<div class="toolbar-timer-grid" data-countdown-grid>
<div class="toolbar-timer-unit">
<span class="toolbar-timer-value" data-countdown-days>0</span>
<span class="toolbar-timer-unit-label">{{ t('countdown_days') }}</span>
</div>
<div class="toolbar-timer-unit">
<span class="toolbar-timer-value" data-countdown-hours>00</span>
<span class="toolbar-timer-unit-label">{{ t('countdown_hours') }}</span>
</div>
<div class="toolbar-timer-unit">
<span class="toolbar-timer-value" data-countdown-minutes>00</span>
<span class="toolbar-timer-unit-label">{{ t('countdown_minutes') }}</span>
</div>
<div class="toolbar-timer-unit">
<span class="toolbar-timer-value" data-countdown-seconds>00</span>
<span class="toolbar-timer-unit-label">{{ t('countdown_seconds') }}</span>
</div>
</div>
<p class="toolbar-timer-subline" data-countdown-subline>{{ t('countdown_subline') }}</p>
</div>
</div>
<div class="toolbar-session-actions">
<a class="btn btn-ghost toolbar-nav-btn" href="{{ url_for('guest_area') }}" aria-label="{{ t('dashboard') }}" title="{{ t('dashboard') }}">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 3h8v8H3V3zm10 0h8v5h-8V3zM3 13h5v8H3v-8zm7 0h11v8H10v-8z" />
</svg>
</a>
<form method="post" action="{{ url_for('logout') }}">
<button class="btn btn-ghost" type="submit">{{ t('logout') }}</button>
</form>
</div>
{% endif %} {% endif %}
</div> </div>
</header> </header>
@@ -51,6 +93,7 @@
<a href="{{ url_for('datenschutz') }}">{{ t('privacy') }}</a> <a href="{{ url_for('datenschutz') }}">{{ t('privacy') }}</a>
<a href="{{ url_for('impressum') }}">{{ t('imprint') }}</a> <a href="{{ url_for('impressum') }}">{{ t('imprint') }}</a>
</footer> </footer>
<script src="{{ url_for('static', filename='dashboard-transition.js', v='20260302c') }}" defer></script> <script src="{{ url_for('static', filename='dashboard-transition.js', v='20260303a') }}" defer></script>
<script src="{{ url_for('static', filename='countdown.js', v='20260303c') }}" defer></script>
</body> </body>
</html> </html>

View File

@@ -25,6 +25,8 @@ services:
- EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026} - EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026}
- HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026} - HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026}
- WEDDING_DATE=${WEDDING_DATE:-} - 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} - SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-268435456} - MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-268435456}
- LOCATION_NAME=${LOCATION_NAME:-Klostermühle} - LOCATION_NAME=${LOCATION_NAME:-Klostermühle}