diff --git a/app.py b/app.py index 7c719f5..8add25d 100644 --- a/app.py +++ b/app.py @@ -2,9 +2,97 @@ from flask import Flask, render_template, request, redirect, url_for from urllib.parse import urlencode import datetime +import json +import os app = Flask(__name__) +# Parameter mapping for URL compression +PARAM_MAP = { + # Long -> Short + 'title': 't', + 'target': 'dt', + 'scheme': 's', + 'accent': 'a', + 'radius': 'r', + 'shadow': 'sh', + 'font': 'f', + 'millis': 'm', + 'rounded_unit': 'ru', + 'bg': 'bg', + 'fg': 'fg', + 'lang': 'l' +} + +# Short -> Long (reverse mapping) +REVERSE_PARAM_MAP = {v: k for k, v in PARAM_MAP.items()} + +def normalize_params(params_dict): + """Convert short parameter names to long ones for internal use""" + normalized = {} + for key, value in params_dict.items(): + # Convert short to long if it exists in reverse map, otherwise keep original + long_key = REVERSE_PARAM_MAP.get(key, key) + normalized[long_key] = value + return normalized + +def compress_params(params_dict): + """Convert long parameter names to short ones for URLs""" + compressed = {} + for key, value in params_dict.items(): + # Convert long to short if it exists in map, otherwise keep original + short_key = PARAM_MAP.get(key, key) + compressed[short_key] = value + return compressed + +def get_param(args, key, default=None): + """Get parameter value checking both long and short names""" + # Try long name first + value = args.get(key, None) + if value is not None: + return value + + # Try short name + short_key = PARAM_MAP.get(key) + if short_key: + value = args.get(short_key, None) + if value is not None: + return value + + return default + +# Load translations +translations = {} +translations_dir = os.path.join(app.root_path, 'translations') +for filename in os.listdir(translations_dir): + if filename.endswith('.json'): + lang_code = filename[:-5] # Remove .json extension + with open(os.path.join(translations_dir, filename), 'r', encoding='utf-8') as f: + translations[lang_code] = json.load(f) + +def get_translation(lang, key, fallback_lang='en'): + """Get translation for a key in specified language, fallback to English if not found""" + keys = key.split('.') + + # Try specified language first + current = translations.get(lang, {}) + for k in keys: + if k in current: + current = current[k] + else: + break + else: + return current + + # Fallback to English + current = translations.get(fallback_lang, {}) + for k in keys: + if k in current: + current = current[k] + else: + return key # Return key if not found + return current + def coerce_bool(val, default=False): if val is None: return default @@ -15,52 +103,91 @@ def coerce_bool(val, default=False): def default_if_none(value, default): return default if value is None or value == "" else value +@app.template_filter("translate") +def translate_filter(key, lang='en'): + return get_translation(lang, key) + +@app.template_filter("url_with_lang") +def url_with_lang_filter(query_string, new_lang): + """Build clean URL with new language parameter using compressed format""" + from urllib.parse import parse_qs, urlencode + + # Parse existing query string + if query_string: + params = parse_qs(query_string) + # Flatten the parameters (parse_qs creates lists) + flat_params = {k: v[0] if v else '' for k, v in params.items() if v} + # Normalize to long parameter names first + normalized = normalize_params(flat_params) + # Remove any existing lang parameters + normalized.pop('lang', None) + else: + normalized = {} + + # Add new language + normalized['lang'] = new_lang + + # Convert back to compressed format + compressed = compress_params(normalized) + + # Build clean query string + return '?' + urlencode(compressed) if compressed else f'?{PARAM_MAP.get("lang", "lang")}={new_lang}' + @app.route("/") def index(): # Prefill from query if present, so users can tweak and regenerate q = request.args + lang = get_param(q, "lang", "en") 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"), + "title": get_param(q, "title", get_translation(lang, "form.countdown_title_placeholder")), + "target": get_param(q, "target", ""), + "scheme": get_param(q, "scheme", "dark"), + "accent": get_param(q, "accent", "#8b5cf6"), # violet-500 + "radius": get_param(q, "radius", "16"), + "shadow": get_param(q, "shadow", "lg"), + "font": get_param(q, "font", "system-ui"), + "show_millis": coerce_bool(get_param(q, "millis"), False), + "rounded_unit": get_param(q, "rounded_unit", "none"), # none, minutes, hours, days + "bg": get_param(q, "bg", "#0b0b10"), + "fg": get_param(q, "fg", "#e5e7eb"), + "lang": lang, + "t": lambda key: get_translation(lang, key) } return render_template("index.html", **context) @app.route("/preview", methods=["POST"]) def preview(): data = request.form.to_dict(flat=True) - # sanitize minimal + # Convert to compressed parameters + compressed_data = compress_params(data) # Build query string and redirect to /countdown - return redirect(url_for("countdown") + "?" + urlencode(data)) + return redirect(url_for("countdown") + "?" + urlencode(compressed_data)) @app.route("/countdown") def countdown(): q = request.args + lang = get_param(q, "lang", "en") # 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"), + "title": get_param(q, "title", get_translation(lang, "form.countdown_title_placeholder")), + "target": get_param(q, "target", ""), + "scheme": get_param(q, "scheme", "dark"), + "accent": get_param(q, "accent", "#8b5cf6"), + "radius": get_param(q, "radius", "16"), + "shadow": get_param(q, "shadow", "lg"), + "font": get_param(q, "font", "system-ui"), + "millis": get_param(q, "millis", "0"), + "rounded_unit": get_param(q, "rounded_unit", "none"), + "bg": get_param(q, "bg", "#0b0b10"), + "fg": get_param(q, "fg", "#e5e7eb"), + "lang": lang, + "t": lambda key: get_translation(lang, key) } # If no target specified, bounce user to setup screen if not params["target"]: - return redirect(url_for("index")) + # Pass lang as short parameter when redirecting + lang_param = PARAM_MAP.get("lang", "lang") + return redirect(url_for("index") + f"?{lang_param}={lang}") return render_template("countdown.html", **params) if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7e10602 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask diff --git a/static/css/styles.css b/static/css/styles.css index 62d2845..6440087 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -144,6 +144,36 @@ button:hover, .share a:hover, .share button:hover { margin-top: 0.5rem; } +.language-selector { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.language-selector a { + padding: 0.4rem 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; + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + opacity: 0.7; + transition: opacity 0.2s, border-color 0.2s; +} + +.language-selector a:hover { + opacity: 1; +} + +.language-selector a.active { + opacity: 1; + border-color: var(--accent); + background: color-mix(in oklab, var(--accent), transparent 90%); +} + @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 index 8f7ad29..39d55e9 100644 --- a/static/js/countdown.js +++ b/static/js/countdown.js @@ -3,9 +3,33 @@ 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"); + // Helper to get parameter from either long or short name + const getParam = (longName, shortName, defaultValue = "") => { + return qs.get(longName) || qs.get(shortName) || ($("#countdown")?.dataset[longName.replace('_', '')] || defaultValue); + }; + + const targetISO = getParam("target", "dt", ""); + const showMillis = (getParam("millis", "m", "0") === "1"); + const roundedUnit = getParam("rounded_unit", "ru", "none"); + const lang = getParam("lang", "l", "en"); + + // Translations + const translations = { + en: { + time_reached: "🎉 It's time!", + invalid_date: "Invalid or missing target date. Use the Edit link to set one.", + copy_link: "Copy sharable link", + link_copied: "Link copied!" + }, + es: { + time_reached: "🎉 ¡Es hora!", + invalid_date: "Fecha objetivo inválida o faltante. Usa el enlace Editar para establecer una.", + copy_link: "Copiar enlace compartible", + link_copied: "¡Enlace copiado!" + } + }; + + const t = (key) => translations[lang]?.[key] || translations.en[key] || key; const targetDate = targetISO ? new Date(targetISO) : null; const targetText = $("#targetText"); @@ -15,17 +39,17 @@ // 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"); + setVar("--accent", getParam("accent", "a")); + setVar("--radius", (getParam("radius", "r", "16")) + "px"); + setVar("--bg", getParam("bg", "bg")); + setVar("--fg", getParam("fg", "fg")); + const scheme = getParam("scheme", "s"); if (scheme) root.setAttribute("data-scheme", scheme); - const font = qs.get("font"); + const font = getParam("font", "f"); 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."; + statusEl.textContent = t("invalid_date"); return; } @@ -57,7 +81,7 @@ $("#m").textContent = "0"; $("#s").textContent = "0"; if (showMillis) $("#ms").textContent = "000"; - statusEl.textContent = "🎉 It's time!"; + statusEl.textContent = t("time_reached"); return; } @@ -83,8 +107,8 @@ copyBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(location.href); - copyBtn.textContent = "Link copied!"; - setTimeout(() => copyBtn.textContent = "Copy sharable link", 1500); + copyBtn.textContent = t("link_copied"); + setTimeout(() => copyBtn.textContent = t("copy_link"), 1500); } catch (e) { alert("Copy failed: " + e); } diff --git a/templates/countdown.html b/templates/countdown.html index a8f4762..2d3b6d9 100644 --- a/templates/countdown.html +++ b/templates/countdown.html @@ -2,27 +2,32 @@ {% extends "base.html" %} {% block content %}
+
+ EN + ES +

{{ title }}

- Target: + {{ t('countdown.target_label') }}

+ data-rounded-unit="{{ rounded_unit }}" + data-lang="{{ lang }}"> -
--
-
--
-
--
-
--
- +
--
+
--
+
--
+
--
+
- - Edit + + {{ t('countdown.edit') }}

diff --git a/templates/index.html b/templates/index.html index 4fc2ebe..91eb53d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,77 +2,81 @@ {% extends "base.html" %} {% block content %}
-

Countdown Generator

+
+ EN + ES +
+

{{ t('app_title') }}

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

- 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. + {{ t('info.description') }}

{% endblock %} diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..1330ecb --- /dev/null +++ b/translations/en.json @@ -0,0 +1,47 @@ +{ + "app_title": "Countdown Generator", + "form": { + "countdown_title_label": "Countdown title", + "countdown_title_placeholder": "My Event", + "target_date_label": "Target date/time (ISO 8601)", + "color_scheme_label": "Color scheme", + "scheme_dark": "Dark", + "scheme_light": "Light", + "accent_color_label": "Accent color", + "foreground_color_label": "Foreground text color", + "background_color_label": "Background color", + "corner_radius_label": "Corner radius (px)", + "shadow_strength_label": "Shadow strength", + "shadow_none": "None", + "shadow_small": "Small", + "shadow_medium": "Medium", + "shadow_large": "Large", + "font_label": "Font (CSS font-family)", + "font_placeholder": "system-ui, 'Segoe UI', sans-serif", + "show_milliseconds_label": "Show milliseconds", + "yes": "Yes", + "no": "No", + "round_to_nearest_label": "Round to nearest", + "round_none": "None", + "round_minutes": "Minutes", + "round_hours": "Hours", + "round_days": "Days", + "generate_button": "Generate Countdown →" + }, + "countdown": { + "target_label": "Target:", + "days": "days", + "hours": "hours", + "minutes": "minutes", + "seconds": "seconds", + "ms": "ms", + "copy_link": "Copy sharable link", + "link_copied": "Link copied!", + "edit": "Edit", + "time_reached": "🎉 It's time!", + "invalid_date": "Invalid or missing target date. Use the Edit link to set one." + }, + "info": { + "description": "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." + } +} \ No newline at end of file diff --git a/translations/es.json b/translations/es.json new file mode 100644 index 0000000..2f31086 --- /dev/null +++ b/translations/es.json @@ -0,0 +1,47 @@ +{ + "app_title": "Generador de Cuenta Regresiva", + "form": { + "countdown_title_label": "Título de la cuenta regresiva", + "countdown_title_placeholder": "Mi Evento", + "target_date_label": "Fecha/hora objetivo (ISO 8601)", + "color_scheme_label": "Esquema de colores", + "scheme_dark": "Oscuro", + "scheme_light": "Claro", + "accent_color_label": "Color de acento", + "foreground_color_label": "Color del texto", + "background_color_label": "Color de fondo", + "corner_radius_label": "Radio de esquinas (px)", + "shadow_strength_label": "Intensidad de sombra", + "shadow_none": "Ninguna", + "shadow_small": "Pequeña", + "shadow_medium": "Mediana", + "shadow_large": "Grande", + "font_label": "Fuente (CSS font-family)", + "font_placeholder": "system-ui, 'Segoe UI', sans-serif", + "show_milliseconds_label": "Mostrar milisegundos", + "yes": "Sí", + "no": "No", + "round_to_nearest_label": "Redondear al más cercano", + "round_none": "Ninguno", + "round_minutes": "Minutos", + "round_hours": "Horas", + "round_days": "Días", + "generate_button": "Generar Cuenta Regresiva →" + }, + "countdown": { + "target_label": "Objetivo:", + "days": "días", + "hours": "horas", + "minutes": "minutos", + "seconds": "segundos", + "ms": "ms", + "copy_link": "Copiar enlace compartible", + "link_copied": "¡Enlace copiado!", + "edit": "Editar", + "time_reached": "🎉 ¡Es hora!", + "invalid_date": "Fecha objetivo inválida o faltante. Usa el enlace Editar para establecer una." + }, + "info": { + "description": "Obtendrás una URL compartible con todas tus configuraciones incrustadas como parámetros de consulta. También puedes acceder a esta página con parámetros de consulta para rellenar el formulario previamente." + } +} \ No newline at end of file