Initial commit
This commit is contained in:
		
						commit
						7d7770b7f9
					
				
							
								
								
									
										42
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @ -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 `/`. | ||||
							
								
								
									
										68
									
								
								app.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								app.py
									
									
									
									
									
										Normal file
									
								
							| @ -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) | ||||
							
								
								
									
										150
									
								
								static/css/styles.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								static/css/styles.css
									
									
									
									
									
										Normal file
									
								
							| @ -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)); } | ||||
| } | ||||
							
								
								
									
										96
									
								
								static/js/countdown.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								static/js/countdown.js
									
									
									
									
									
										Normal file
									
								
							| @ -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); | ||||
| })(); | ||||
							
								
								
									
										26
									
								
								templates/base.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								templates/base.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | ||||
| 
 | ||||
| <!DOCTYPE html> | ||||
| <html lang="en" data-scheme="{{ scheme|default_if_none('dark') }}"> | ||||
| <head> | ||||
|   <meta charset="utf-8"> | ||||
|   <meta name="viewport" content="width=device-width,initial-scale=1"> | ||||
|   <title>{{ title|default_if_none("Big Day") }}</title> | ||||
|   <meta name="color-scheme" content="{{ 'dark' if scheme=='dark' else 'light' }}"> | ||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> | ||||
|   <style> | ||||
|     :root { | ||||
|       --accent: {{ accent|default_if_none('#8b5cf6') }}; | ||||
|       --radius: {{ radius|default_if_none('16') }}px; | ||||
|       --bg: {{ bg|default_if_none('#0b0b10') }}; | ||||
|       --fg: {{ fg|default_if_none('#e5e7eb') }}; | ||||
|       --font: {{ font|default_if_none('system-ui') }}; | ||||
|     } | ||||
|   </style> | ||||
| </head> | ||||
| <body> | ||||
|   <main class="container"> | ||||
|     {% block content %}{% endblock %} | ||||
|   </main> | ||||
|   {% block scripts %}{% endblock %} | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										33
									
								
								templates/countdown.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								templates/countdown.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | ||||
| 
 | ||||
| {% extends "base.html" %} | ||||
| {% block content %} | ||||
|   <section class="card {{ 'shadow-' + shadow if shadow != 'none' else '' }}"> | ||||
|     <h1 class="title">{{ title }}</h1> | ||||
|     <p class="subtitle"> | ||||
|       Target: <code id="targetText"></code> | ||||
|     </p> | ||||
| 
 | ||||
|     <div id="countdown"  | ||||
|          class="countdown" | ||||
|          data-target="{{ target }}" | ||||
|          data-show-millis="{{ millis }}" | ||||
|          data-rounded-unit="{{ rounded_unit }}"> | ||||
|       <!-- JS populates these --> | ||||
|       <div class="unit"><span id="d">--</span><label>days</label></div> | ||||
|       <div class="unit"><span id="h">--</span><label>hours</label></div> | ||||
|       <div class="unit"><span id="m">--</span><label>minutes</label></div> | ||||
|       <div class="unit"><span id="s">--</span><label>seconds</label></div> | ||||
|       <div class="unit millis" id="millisWrap" hidden><span id="ms">---</span><label>ms</label></div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="share"> | ||||
|       <button id="copyLink">Copy sharable link</button> | ||||
|       <a id="editLink" href="{{ url_for('index') }}?{{ request.query_string.decode('utf-8') }}">Edit</a> | ||||
|     </div> | ||||
| 
 | ||||
|     <p id="status" class="muted"></p> | ||||
|   </section> | ||||
| {% endblock %} | ||||
| {% block scripts %} | ||||
|   <script src="{{ url_for('static', filename='js/countdown.js') }}"></script> | ||||
| {% endblock %} | ||||
							
								
								
									
										78
									
								
								templates/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								templates/index.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,78 @@ | ||||
| 
 | ||||
| {% extends "base.html" %} | ||||
| {% block content %} | ||||
|   <section class="card"> | ||||
|     <h1>Countdown Generator</h1> | ||||
|     <form method="post" action="{{ url_for('preview') }}" class="grid"> | ||||
|       <div class="field"> | ||||
|         <label>Countdown title</label> | ||||
|         <input type="text" name="title" placeholder="My Event" value="{{ title }}"> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Target date/time (ISO 8601)</label> | ||||
|         <input type="datetime-local" name="target" value="{{ target }}"> | ||||
|          | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Color scheme</label> | ||||
|         <select name="scheme"> | ||||
|           <option value="dark" {{ 'selected' if scheme=='dark' else '' }}>Dark</option> | ||||
|           <option value="light" {{ 'selected' if scheme=='light' else '' }}>Light</option> | ||||
|         </select> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Accent color</label> | ||||
|         <input type="color" name="accent" value="{{ accent }}"> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Foreground text color</label> | ||||
|         <input type="color" name="fg" value="{{ fg }}"> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Background color</label> | ||||
|         <input type="color" name="bg" value="{{ bg }}"> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Corner radius (px)</label> | ||||
|         <input type="number" name="radius" min="0" max="48" step="1" value="{{ radius }}"> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Shadow strength</label> | ||||
|         <select name="shadow"> | ||||
|           <option value="none" {{ 'selected' if shadow=='none' else '' }}>None</option> | ||||
|           <option value="sm" {{ 'selected' if shadow=='sm' else '' }}>Small</option> | ||||
|           <option value="md" {{ 'selected' if shadow=='md' else '' }}>Medium</option> | ||||
|           <option value="lg" {{ 'selected' if shadow=='lg' else '' }}>Large</option> | ||||
|         </select> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Font (CSS font-family)</label> | ||||
|         <input type="text" name="font" value="{{ font }}" placeholder="system-ui, 'Segoe UI', sans-serif"> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Show milliseconds</label> | ||||
|         <select name="millis"> | ||||
|           <option value="0" {{ '' if show_millis else 'selected' }}>No</option> | ||||
|           <option value="1" {{ 'selected' if show_millis else '' }}>Yes</option> | ||||
|         </select> | ||||
|       </div> | ||||
|       <div class="field"> | ||||
|         <label>Round to nearest</label> | ||||
|         <select name="rounded_unit"> | ||||
|           <option value="none" {{ 'selected' if rounded_unit=='none' else '' }}>None</option> | ||||
|           <option value="minutes" {{ 'selected' if rounded_unit=='minutes' else '' }}>Minutes</option> | ||||
|           <option value="hours" {{ 'selected' if rounded_unit=='hours' else '' }}>Hours</option> | ||||
|           <option value="days" {{ 'selected' if rounded_unit=='days' else '' }}>Days</option> | ||||
|         </select> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="actions"> | ||||
|         <button type="submit">Generate Countdown →</button> | ||||
|       </div> | ||||
|     </form> | ||||
|     <p class="muted"> | ||||
|       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. | ||||
|     </p> | ||||
|   </section> | ||||
| {% endblock %} | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user