From 7d7770b7f9b1c62f6cebe441a6ae99e3b405f9b2 Mon Sep 17 00:00:00 2001 From: Ignacio Rivero Date: Tue, 12 Aug 2025 22:28:56 -0300 Subject: [PATCH] Initial commit --- README.md | 42 +++++++++++ app.py | 68 ++++++++++++++++++ static/css/styles.css | 150 +++++++++++++++++++++++++++++++++++++++ static/js/countdown.js | 96 +++++++++++++++++++++++++ templates/base.html | 26 +++++++ templates/countdown.html | 33 +++++++++ templates/index.html | 78 ++++++++++++++++++++ 7 files changed, 493 insertions(+) create mode 100644 README.md create mode 100644 app.py create mode 100644 static/css/styles.css create mode 100644 static/js/countdown.js create mode 100644 templates/base.html create mode 100644 templates/countdown.html create mode 100644 templates/index.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..0027b3e --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ + +# Countdown Generator (Flask + Jinja) + +A tiny two-page app: +- `/` – a setup form to pick the target date/time and styles. +- `/countdown?…` – the shareable countdown page, configured entirely from query params. + +## Quick start + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install flask +python app.py +# open http://127.0.0.1:5000/ +``` + +## URL Parameters + +- `target` — ISO-8601 local datetime (e.g. `2025-09-06T12:00`) +- `title` — heading text +- `scheme` — `dark` or `light` +- `accent` — CSS color (e.g. `#ff6b6b` or `hsl(260 90% 60%)`) +- `fg` / `bg` — text/background colors +- `radius` — number (px) for rounded corners +- `shadow` — `none|sm|md|lg` +- `font` — CSS font-family string +- `millis` — `1` to show milliseconds +- `rounded_unit` — `none|minutes|hours|days` (rounds to nearest) + +## Example + +``` +/countdown?title=Ignacio%27s%20Trip&target=2025-09-06T12:00&scheme=dark&accent=%238b5cf6&radius=20&shadow=lg&font=system-ui&millis=1 +``` + +## Notes + +- The generator reflects any params back into the form, so you can tweak and re-generate links. +- No external assets required. Everything is pure HTML/CSS/JS with Flask+Jinja. +- The countdown runs with `requestAnimationFrame` and updates at ~60Hz (with ms on if enabled). +- If no `target` is provided, `/countdown` redirects to `/`. diff --git a/app.py b/app.py new file mode 100644 index 0000000..7c719f5 --- /dev/null +++ b/app.py @@ -0,0 +1,68 @@ + +from flask import Flask, render_template, request, redirect, url_for +from urllib.parse import urlencode +import datetime + +app = Flask(__name__) + +def coerce_bool(val, default=False): + if val is None: + return default + s = str(val).lower() + return s in ("1","true","yes","y","on") + +@app.template_filter("default_if_none") +def default_if_none(value, default): + return default if value is None or value == "" else value + +@app.route("/") +def index(): + # Prefill from query if present, so users can tweak and regenerate + q = request.args + context = { + "title": q.get("title", "Big Day"), + "target": q.get("target", ""), + "scheme": q.get("scheme", "dark"), + "accent": q.get("accent", "#8b5cf6"), # violet-500 + "radius": q.get("radius", "16"), + "shadow": q.get("shadow", "lg"), + "font": q.get("font", "system-ui"), + "show_millis": coerce_bool(q.get("millis"), False), + "rounded_unit": q.get("rounded_unit", "none"), # none, minutes, hours, days + "bg": q.get("bg", "#0b0b10"), + "fg": q.get("fg", "#e5e7eb"), + } + return render_template("index.html", **context) + +@app.route("/preview", methods=["POST"]) +def preview(): + data = request.form.to_dict(flat=True) + # sanitize minimal + # Build query string and redirect to /countdown + return redirect(url_for("countdown") + "?" + urlencode(data)) + +@app.route("/countdown") +def countdown(): + q = request.args + # Provide sane defaults if not supplied + params = { + "title": q.get("title", "Big Day"), + "target": q.get("target", ""), + "scheme": q.get("scheme", "dark"), + "accent": q.get("accent", "#8b5cf6"), + "radius": q.get("radius", "16"), + "shadow": q.get("shadow", "lg"), + "font": q.get("font", "system-ui"), + "millis": q.get("millis", "0"), + "rounded_unit": q.get("rounded_unit", "none"), + "bg": q.get("bg", "#0b0b10"), + "fg": q.get("fg", "#e5e7eb"), + } + # If no target specified, bounce user to setup screen + if not params["target"]: + return redirect(url_for("index")) + return render_template("countdown.html", **params) + +if __name__ == "__main__": + # Helpful for local dev + app.run(debug=True, host="0.0.0.0", port=5000) diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..62d2845 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,150 @@ + +:root { + --accent: #8b5cf6; + --bg: #0b0b10; + --fg: #e5e7eb; + --radius: 16px; + --font: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; +} + +html { + font-family: var(--font); +} + +html, body { + height: 100%; +} + +html[data-scheme="dark"] body { + background: var(--bg); + color: var(--fg); +} + +html[data-scheme="light"] body { + background: var(--bg); + color: var(--fg); +} + +.container { + min-height: 100%; + display: grid; + place-items: center; + padding: 2rem; +} + +.card { + width: min(900px, 95vw); + background: color-mix(in oklab, var(--bg), white 7%); + border: 1px solid color-mix(in oklab, var(--bg), white 15%); + border-radius: var(--radius); + padding: 1.5rem; +} + +.shadow-sm { box-shadow: 0 2px 10px rgba(0,0,0,.15); } +.shadow-md { box-shadow: 0 10px 25px rgba(0,0,0,.2); } +.shadow-lg { box-shadow: 0 30px 60px rgba(0,0,0,.25); } + +h1.title { + font-family: inherit; + font-size: clamp(2rem, 4vw + 1rem, 4rem); + line-height: 1.1; + margin: 0 0 0.5rem 0; +} + +.subtitle { + margin-top: 0; + opacity: .8; +} + +.countdown { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 1rem; + margin: 1.5rem 0; +} + +.countdown .unit { + display: grid; + place-items: center; + padding: 1rem; + border-radius: calc(var(--radius) - 6px); + border: 1px solid color-mix(in oklab, var(--bg), white 15%); + background: color-mix(in oklab, var(--bg), white 5%); +} + +.countdown .unit span { + font-family: inherit; + font-size: clamp(1.8rem, 5vw + 0.5rem, 4rem); + font-variant-numeric: tabular-nums; +} + +.countdown .unit label { + font-family: inherit; + opacity: .7; + font-size: 0.9rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem 1.25rem; +} + +.field { + display: grid; + gap: 0.5rem; +} + +.field input[type="text"], +.field input[type="datetime-local"], +.field input[type="number"], +.field select, +.field input[type="color"] { + padding: 0.6rem 0.75rem; + border-radius: calc(var(--radius) - 10px); + border: 1px solid color-mix(in oklab, var(--bg), white 20%); + background: color-mix(in oklab, var(--bg), white 7%); + color: inherit; + font-family: var(--font); +} + +.actions { + grid-column: 1 / -1; + margin-top: 0.5rem; +} + +button, .actions button, .share a, .share button { + font-size: inherit; + appearance: none; + border: 0; + padding: 0.75rem 1rem; + border-radius: calc(var(--radius) - 8px); + background: var(--accent); + color: var(--fg); + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-block; +} + +button:hover, .share a:hover, .share button:hover { + filter: brightness(1.05); +} + +.muted { + opacity: .8; + font-size: 0.95rem; +} + +.share { + display: flex; + gap: 0.75rem; + align-items: center; + margin-top: 0.5rem; +} + +@media (max-width: 720px) { + .grid { grid-template-columns: 1fr; } + .countdown { grid-template-columns: repeat(2, minmax(0,1fr)); } +} diff --git a/static/js/countdown.js b/static/js/countdown.js new file mode 100644 index 0000000..8f7ad29 --- /dev/null +++ b/static/js/countdown.js @@ -0,0 +1,96 @@ + +(function() { + const $ = (sel) => document.querySelector(sel); + const qs = new URLSearchParams(location.search); + + const targetISO = qs.get("target") || ($("#countdown")?.dataset.target || ""); + const showMillis = (qs.get("millis") ?? ($("#countdown")?.dataset.showMillis || "0")) === "1"; + const roundedUnit = qs.get("rounded_unit") || ($("#countdown")?.dataset.roundedUnit || "none"); + + const targetDate = targetISO ? new Date(targetISO) : null; + const targetText = $("#targetText"); + const statusEl = $("#status"); + const msWrap = $("#millisWrap"); + + // Reflect chosen colors into CSS variables if passed via query + const root = document.documentElement; + const setVar = (k, v) => { if (v) root.style.setProperty(k, v); }; + setVar("--accent", qs.get("accent")); + setVar("--radius", (qs.get("radius") || "16") + "px"); + setVar("--bg", qs.get("bg")); + setVar("--fg", qs.get("fg")); + const scheme = qs.get("scheme"); + if (scheme) root.setAttribute("data-scheme", scheme); + const font = qs.get("font"); + if (font) root.style.setProperty("--font", font.includes(" ") ? `"${font}"` : font); + + if (!targetDate || isNaN(targetDate.valueOf())) { + statusEl.textContent = "Invalid or missing target date. Use the Edit link to set one."; + return; + } + + targetText.textContent = targetDate.toString(); + + msWrap.hidden = !showMillis; + if (!showMillis && msWrap.parentNode) { msWrap.parentNode.removeChild(msWrap); } + + function roundToUnit(ms, unit) { + if (unit === "none") return ms; + const map = { + minutes: 60_000, + hours: 3_600_000, + days: 86_400_000, + }; + const size = map[unit] || 1; + return Math.round(ms / size) * size; + } + + function tick() { + const now = new Date(); + let diff = targetDate - now; + if (roundedUnit !== "none") { + diff = roundToUnit(diff, roundedUnit); + } + if (diff <= 0) { + $("#d").textContent = "0"; + $("#h").textContent = "0"; + $("#m").textContent = "0"; + $("#s").textContent = "0"; + if (showMillis) $("#ms").textContent = "000"; + statusEl.textContent = "🎉 It's time!"; + return; + } + + const totalSeconds = Math.floor(diff / 1000); + const days = Math.floor(totalSeconds / (3600*24)); + const hours = Math.floor((totalSeconds % (3600*24)) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const millis = diff % 1000; + + $("#d").textContent = String(days); + $("#h").textContent = String(hours).padStart(2, "0"); + $("#m").textContent = String(minutes).padStart(2, "0"); + $("#s").textContent = String(seconds).padStart(2, "0"); + if (showMillis) $("#ms").textContent = String(millis).padStart(3, "0"); + + requestAnimationFrame(tick); + } + + // Shareable link + const copyBtn = $("#copyLink"); + if (copyBtn) { + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(location.href); + copyBtn.textContent = "Link copied!"; + setTimeout(() => copyBtn.textContent = "Copy sharable link", 1500); + } catch (e) { + alert("Copy failed: " + e); + } + }); + } + + // Start + requestAnimationFrame(tick); +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a89a72f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,26 @@ + + + + + + + {{ title|default_if_none("Big Day") }} + + + + + +
+ {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/templates/countdown.html b/templates/countdown.html new file mode 100644 index 0000000..a8f4762 --- /dev/null +++ b/templates/countdown.html @@ -0,0 +1,33 @@ + +{% extends "base.html" %} +{% block content %} +
+

{{ title }}

+

+ Target: +

+ +
+ +
--
+
--
+
--
+
--
+ +
+ + + +

+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..4fc2ebe --- /dev/null +++ b/templates/index.html @@ -0,0 +1,78 @@ + +{% extends "base.html" %} +{% block content %} +
+

Countdown Generator

+
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+

+ You'll get a shareable URL with all your settings embedded as query parameters. + You can also hit this page with query params to prefill the form. +

+
+{% endblock %}