From 7c44c1f033062f1addf88c272f858e12b80c584b Mon Sep 17 00:00:00 2001 From: MadanBelbase Date: Thu, 7 May 2026 22:39:58 +0545 Subject: [PATCH 1/3] Added : Frontend --- osmsg/Frontend/app.py | 176 +++ osmsg/Frontend/static/css/style.css | 1353 +++++++++++++++++ osmsg/Frontend/static/js/main.js | 66 + osmsg/Frontend/templates/about.html | 117 ++ osmsg/Frontend/templates/base.html | 28 + .../Frontend/templates/components/footer.html | 19 + .../Frontend/templates/components/navbar.html | 23 + osmsg/Frontend/templates/contact.html | 57 + osmsg/Frontend/templates/index.html | 154 ++ osmsg/Frontend/templates/partial/table.html | 142 ++ osmsg/Frontend/templates/statistics.html | 216 +++ 11 files changed, 2351 insertions(+) create mode 100644 osmsg/Frontend/app.py create mode 100644 osmsg/Frontend/static/css/style.css create mode 100644 osmsg/Frontend/static/js/main.js create mode 100644 osmsg/Frontend/templates/about.html create mode 100644 osmsg/Frontend/templates/base.html create mode 100644 osmsg/Frontend/templates/components/footer.html create mode 100644 osmsg/Frontend/templates/components/navbar.html create mode 100644 osmsg/Frontend/templates/contact.html create mode 100644 osmsg/Frontend/templates/index.html create mode 100644 osmsg/Frontend/templates/partial/table.html create mode 100644 osmsg/Frontend/templates/statistics.html diff --git a/osmsg/Frontend/app.py b/osmsg/Frontend/app.py new file mode 100644 index 0000000..15936a5 --- /dev/null +++ b/osmsg/Frontend/app.py @@ -0,0 +1,176 @@ +from flask import Flask, render_template, request, jsonify +import urllib.request +import urllib.parse +import json +from datetime import datetime, timedelta + +app = Flask(__name__) + +API_BASE = "https://osmsg-1.onrender.com/api/v1/user-stats" + + +@app.context_processor +def inject_globals(): + return { + "current_year": datetime.utcnow().year + } + + +def _parse_dates(daterange_str): + + now = datetime.utcnow() + yesterday = now - timedelta(days=1) + + fallback = ( + yesterday.strftime("%Y-%m-%dT00:00:00Z"), + now.strftime("%Y-%m-%dT23:59:59Z"), + ) + + if not daterange_str: + return fallback + + try: + if "to" in daterange_str: + left, right = daterange_str.split("to", 1) + d1 = datetime.strptime(left.strip(), "%d-%m-%Y") + d2 = datetime.strptime(right.strip(), "%d-%m-%Y") + else: + d1 = d2 = datetime.strptime(daterange_str.strip(), "%d-%m-%Y") + + return ( + d1.strftime("%Y-%m-%dT00:00:00Z"), + d2.strftime("%Y-%m-%dT23:59:59Z"), + ) + + except (ValueError, AttributeError): + return fallback + + +def _fetch(params): + + url = f"{API_BASE}?{urllib.parse.urlencode(params)}" + + req = urllib.request.Request( + url, + headers={"Accept": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=90) as resp: + raw = resp.read().decode() + if not raw.strip(): + return {} + return json.loads(raw) + + + +@app.route("/") +def index(): + return render_template("index.html") + +@app.route("/statistics") +def statistics(): + + hashtag = request.args.get("hashtag", "").strip() + daterange = request.args.get("daterange", "").strip() + limit = int(request.args.get("limit", 25)) + offset = int(request.args.get("offset", 0)) + + start, end = _parse_dates(daterange) + + params = { + "start": start, + "end": end, + "limit": limit, + "offset": offset, + } + + if hashtag: + params["hashtag"] = hashtag + + try: + raw = _fetch(params) + users = raw.get("users", []) + count = raw.get("count", len(users)) + meta = { + "start": raw.get("start", start), + "end": raw.get("end", end), + "hashtag": raw.get("hashtag", hashtag), + "limit": raw.get("limit", limit), + "offset": raw.get("offset", offset), + "count": count, + } + error = None + + except Exception as exc: + users = [] + meta = { + "start": start, + "end": end, + "hashtag": hashtag, + "limit": limit, + "offset": offset, + "count": 0, + } + error = str(exc) + + is_htmx = request.headers.get("HX-Request") == "true" + template = "partial/table.html" if is_htmx else "statistics.html" + + return render_template( + template, + hashtag=hashtag, + daterange=daterange, + users=users, + meta=meta, + error=error, + limit=limit, + offset=offset, + ) + + +@app.route("/api/proxy") +def api_proxy(): + + hashtag = request.args.get("hashtag", "").strip() + daterange = request.args.get("daterange", "").strip() + limit = int(request.args.get("limit", 25)) + offset = int(request.args.get("offset", 0)) + + start, end = _parse_dates(daterange) + + params = { + "start": start, + "end": end, + "limit": limit, + "offset": offset, + } + + if hashtag: + params["hashtag"] = hashtag + + try: + return jsonify(_fetch(params)) + + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + return jsonify({"error": f"HTTP {exc.code}", "detail": body}), + + except urllib.error.URLError as exc: + return jsonify({"error": "Upstream unreachable", "detail": str(exc.reason)}), + + except Exception as exc: + return jsonify({"error": str(exc)}) + + +@app.route("/about") +def about(): + return render_template("about.html") + + +@app.route("/contact") +def contact(): + return render_template("contact.html") + + +if __name__ == "__main__": + app.run(debug=True, port=5004) \ No newline at end of file diff --git a/osmsg/Frontend/static/css/style.css b/osmsg/Frontend/static/css/style.css new file mode 100644 index 0000000..59b6cb1 --- /dev/null +++ b/osmsg/Frontend/static/css/style.css @@ -0,0 +1,1353 @@ +/* Design tokens */ +:root { + --primary: #2b6f4b; + --primary-dark: #1d543a; + --primary-light: #e9f2ee; + --text: #2d3f4b; + --text-dark: #0c2b35; + --border: #e3eee8; + --surface: #ffffff; + --surface-alt: #f8fafc; + --error: #c62828; + --error-bg: #ffebee; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; + background-color: #f8fafc; + color: #0b2b3c; + line-height: 1.5; +} + +/* Global container for consistent spacing */ +.container { + max-width: 1280px; + margin: 0 auto; + padding: 0 2rem; +} + +/* header / navigation */ +.navbar { + background-color: #ffffff; + border-bottom: 1px solid #e2e8f0; + padding: 0.75rem 0; + position: sticky; + top: 0; + backdrop-filter: blur(8px); + background-color: rgba(255, 255, 255, 0.9); + z-index: 10; +} + +.nav-container { + max-width: 1280px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.logo-area { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.logo-icon { + background: #2b6f4b; + color: white; + font-weight: 700; + font-size: 1.4rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + box-shadow: 0 4px 6px -1px rgba(43, 111, 75, 0.3); +} + +.logo-text { + font-size: 1.6rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.logo-text span { + color: #2b6f4b; + font-weight: 700; +} + +.logo-small { + font-size: 0.9rem; + background: #e9f2ee; + padding: 0.2rem 0.8rem; + border-radius: 30px; + color: #1e4a36; + font-weight: 500; + margin-left: 0.5rem; +} + +.nav-links { + display: flex; + gap: 2rem; + align-items: center; +} + +.nav-links a { + text-decoration: none; + font-weight: 500; + color: #2d3f4b; + transition: color 0.15s; + font-size: 1rem; +} + +.nav-links a:hover { + color: #2b6f4b; +} + +/* Hero Section */ +.hero { + padding: 4rem 0; + display: flex; + justify-content: center; +} + +.hero-grid { + max-width: 1280px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + flex-wrap: wrap; + gap: 3rem; + align-items: flex-start; + width: 100%; +} + +.hero-left { + flex: 1 1 400px; +} + +.hero-badge { + background: #e2f0e9; + color: #1c4b34; + display: inline-block; + padding: 0.3rem 1rem; + border-radius: 50px; + font-weight: 500; + font-size: 0.9rem; + margin-bottom: 1rem; + border: 1px solid #bcd9cc; +} + +.hero-left h1 { + font-size: 2.8rem; + font-weight: 700; + line-height: 1.2; + margin-bottom: 1rem; + color: #0c2b35; +} + +.hero-left p { + font-size: 1.1rem; + color: #2d4b5a; + margin-bottom: 2rem; + max-width: 500px; +} + +.hero-stats { + display: flex; + gap: 2rem; + margin-bottom: 2rem; +} + +.stat-item { + display: flex; + flex-direction: column; +} + +.stat-number { + font-size: 1.6rem; + font-weight: 700; + color: #2b6f4b; +} + +.stat-label { + font-size: 0.85rem; + color: #547687; +} + +.cta-group { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.btn-primary { + background: #2b6f4b; + color: white; + padding: 0.8rem 2rem; + border-radius: 40px; + font-weight: 600; + border: none; + cursor: pointer; + transition: background 0.15s; + text-decoration: none; + display: inline-block; +} + +.btn-primary:hover { + background: #1d543a; +} + +.btn-secondary { + background: white; + color: #1e4a36; + padding: 0.8rem 2rem; + border-radius: 40px; + border: 1px solid #b3cfc2; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + text-decoration: none; + display: inline-block; +} + +.btn-secondary:hover { + background: #eff8f3; + border-color: #2b6f4b; +} + +.hero-right { + flex: 0 1 380px; + background: white; + border-radius: 24px; + padding: 1.5rem; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08); + border: 1px solid #dee9e4; +} + +.sample-json { + background: #1a2c33; + color: #d6eee3; + padding: 1rem; + border-radius: 16px; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.8rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + border-left: 5px solid #3e9b70; +} + +/* Search Section */ +.search-section { + max-width: 1280px; + margin: 0 auto; + padding: 0 2rem; +} + +.form-title { + font-size: 1.6rem; + font-weight: 600; + margin-bottom: 1rem; + color: #14323e; +} + +.horizontal-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem; + margin: 0 0 2rem 0; + padding: 1.5rem; + background: white; + border-radius: 16px; + border: 1px solid #e3eee8; + box-shadow: 0 10px 25px -10px rgba(0, 0, 0, 0.08); +} + +.form-group { + display: flex; + flex-direction: column; + flex: 1 1 180px; + min-width: 140px; +} + +.form-group label { + font-size: 0.85rem; + margin-bottom: 0.3rem; + color: #4a6b78; + font-weight: 500; +} + +.form-group input, +.form-group select { + padding: 0.7rem 0.8rem; + border-radius: 10px; + border: 1.5px solid #d7e5df; + font-size: 0.95rem; + background: white; + transition: all 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + border-color: #2b6f4b; + outline: none; + box-shadow: 0 0 0 3px rgba(43, 111, 75, 0.1); +} + +.search-btn { + background: #2b6f4b; + color: white; + padding: 0.7rem 1.8rem; + border: none; + border-radius: 10px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + font-size: 0.95rem; + white-space: nowrap; +} + +.search-btn:hover { + background: #1d543a; +} + +/* Feature Section */ +.feature-section { + max-width: 1280px; + margin: 0 auto; + padding: 0 2rem; +} + +.section-title { + font-size: 2rem; + font-weight: 600; + margin: 2rem 0 1rem 0; + color: #14323e; +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 2rem; + margin: 0 0 2rem 0; +} + +.card { + background: white; + border-radius: 24px; + padding: 2rem; + border: 1px solid #e3eee8; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.05); + transition: transform 0.15s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.12); +} + + +.card h3 { + font-size: 1.3rem; + margin-bottom: 0.75rem; + color: #0c2b35; +} + +.card p { + color: #3b5f6b; + margin-bottom: 1rem; + line-height: 1.5; +} + +.card code { + background: #eef2f0; + padding: 0.2rem 0.4rem; + border-radius: 6px; + font-size: 0.85rem; + color: #2b6f4b; +} + + +/* footer */ +.footer { + max-width: 1280px; + margin: 0 auto; + border-top: 1px solid #d7e5df; + margin-top: 4rem; + padding: 2.5rem 2rem 3rem; + color: #2c5e4a; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; +} + +.footer-links { + display: flex; + gap: 2rem; +} + +.footer-links a { + text-decoration: none; + color: #316e53; + font-weight: 500; +} + +.i-btn { + padding: 0.6rem 0.6rem; + background: #e2f0e9; + border: none; + border-radius: 8px; + cursor: pointer; + color: #2b6f4b; + font-weight: bold; + transition: background 0.2s; +} + +.i-btn:hover { + background: #c5e1d3; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .horizontal-form { + flex-wrap: wrap; + } + + .form-group { + flex: 1 1 calc(33.333% - 1rem); + } + + .search-btn { + flex: 0 0 auto; + } +} + +@media (max-width: 768px) { + .nav-container { + flex-direction: column; + gap: 0.8rem; + padding: 0 1rem; + } + + .hero-grid { + flex-direction: column; + align-items: center; + padding: 0 1rem; + } + + .hero-left h1 { + font-size: 2.2rem; + text-align: center; + } + + .hero-left p { + text-align: center; + max-width: 100%; + } + + .hero-stats { + justify-content: center; + } + + .cta-group { + justify-content: center; + } + + .hero { + padding: 2rem 0; + } + + .search-section { + padding: 0 1rem; + } + + .horizontal-form { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .form-group { + flex: 1 1 auto; + } + + .search-btn { + width: 100%; + padding: 0.8rem; + } + + .feature-section { + padding: 0 1rem; + } + + .section-title { + text-align: center; + } + + .footer { + flex-direction: column; + gap: 1.5rem; + text-align: center; + padding: 2rem 1rem; + } + + .footer-links { + flex-wrap: wrap; + justify-content: center; + } +} + +@media (max-width: 600px) { + .hero-left h1 { + font-size: 1.8rem; + } + + .hero-left p { + font-size: 1rem; + } + + .cta-group { + flex-direction: column; + width: 100%; + } + + .btn-primary, + .btn-secondary { + text-align: center; + width: 100%; + } + + .hero-stats { + flex-wrap: wrap; + justify-content: center; + gap: 1.5rem; + } + + .stat-item { + align-items: center; + } +} + + +.nav-links a, +.footer-links a { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.nav-link-icon, +.footer-link-icon { + font-size: 1rem; +} + +.footer-divider { + border: 1px solid #d2e8df; +} + +.footer-info { + font-weight: 500; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.footer-info-icon { + font-size: 1.1rem; + color: #2b6f4b; +} + +/* Contact Page Styles */ +.contact-page-container { + margin-top: 4rem; + margin-bottom: 6rem; +} + +.contact-card { + background: white; + padding: 3rem; + border-radius: 2rem; + box-shadow: 0 15px 35px -12px rgba(0, 0, 0, 0.08); + max-width: 800px; + margin: 0 auto; +} + +.contact-title { + font-size: 2.5rem; + color: #1a4a36; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.contact-title-icon { + font-size: 2.2rem; + color: #2b6f4b; +} + +.contact-subtitle { + font-size: 1.1rem; + color: #2d4b5a; + margin-bottom: 2rem; +} + +.contact-grid { + display: grid; + gap: 2rem; +} + +.contact-item { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.contact-item-title { + font-size: 1.2rem; + color: #14323e; +} + +.contact-item-desc { + color: #4a6b78; +} + +.contact-link { + color: #2b6f4b; + text-decoration: none; +} + +.contact-icon-mail { + font-size: 2rem; + color: #e53935; + margin-top: 0.1rem; +} + +.contact-icon-twitter { + font-size: 2rem; + color: #1da1f2; + margin-top: 0.1rem; +} + +.contact-icon-github { + font-size: 2rem; + color: #333; + margin-top: 0.1rem; +} + +/* Statistics Page Styles */ +.stats-header-container { + margin-top: 2rem; + background: #f8fafc; + padding: 1.5rem; + border-radius: 1rem; + border: 1px solid #e3eee8; +} + +.stats-title { + color: #1a4a36; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.stats-title-icon { + color: #f5a623; +} + +.stats-list { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.stats-list-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.8rem 1rem; + background: white; + border-radius: 0.7rem; + border: 1px solid #e3eee8; +} + +.stats-user-info { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.stats-rank-gold { + color: #FFD700; + font-size: 1.3rem; +} + +.stats-rank-silver { + color: #C0C0C0; + font-size: 1.3rem; +} + +.stats-rank-bronze { + color: #CD7F32; + font-size: 1.3rem; +} + +.stats-rank-other { + width: 1.3rem; + text-align: center; + font-size: 0.85rem; + color: #90a4ae; + font-weight: 600; +} + +.stats-user-name { + font-weight: 500; + color: #1a4a36; +} + +.stats-contribution-count { + display: flex; + align-items: center; + gap: 0.4rem; + background: #f0f7f4; + padding: 0.3rem 0.8rem; + border-radius: 2rem; +} + +.stats-contribution-icon { + font-size: 1rem; + color: #2b6f4b; +} + +.stats-contribution-number { + color: #1a4a36; +} + +.stats-contribution-label { + font-size: 0.8rem; + color: #4a6b78; +} + +.stats-charts-container { + margin-top: 2rem; + display: flex; + gap: 2rem; + flex-wrap: wrap; + justify-content: center; +} + +.stats-chart-card { + width: 380px; + height: 260px; + background: #f8fafc; + padding: 1.2rem; + border-radius: 1rem; + border: 1px solid #e3eee8; +} + +.stats-chart-title { + font-size: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.stats-chart-icon-bar { + color: #42a5f5; + font-size: 1.2rem; +} + +.stats-chart-icon-line { + color: #66bb6a; + font-size: 1.2rem; +} + +.stats-chart-icon-donut { + color: #ab47bc; + font-size: 1.2rem; +} + +/* Index page styles */ +.index-container { + margin-top: 6rem; + margin-bottom: 6rem; +} + +.search-section-centered { + max-width: 1000px; + margin: 0 auto; +} + +.search-form-title { + text-align: center; + font-size: 2.2rem; + margin-bottom: 2rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.search-title-icon { + font-size: 2.2rem; + color: #2b6f4b; +} + +.form-group-200 { + flex: 1 1 200px; +} + +.form-group-300 { + flex: 1 1 300px; +} + +.form-label { + font-size: 0.85rem; + margin-bottom: 0.4rem; + color: #4a6b78; + display: flex; + align-items: center; + gap: 0.3rem; +} + +.form-label-icon { + font-size: 1rem; +} + +.input-wrapper { + position: relative; +} + +.hashtag-input { + width: 100%; + padding: 0.7rem; + padding-right: 2.5rem; + border-radius: 8px; + border: 1.5px solid #d7e5df; + box-sizing: border-box; +} + +.hashtag-icon { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + font-size: 1.1rem; + color: #90a4ae; + pointer-events: none; +} + +.hashtag-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1.5px solid #d7e5df; + border-radius: 8px; + margin-top: 0.2rem; + z-index: 100; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.hashtag-option { + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.9rem; + color: #1a4a36; + display: flex; + align-items: center; + gap: 0.5rem; + transition: background 0.15s; +} + +.hashtag-option:hover { + background: #f0f7f4; +} + +.hashtag-option-icon { + font-size: 0.95rem; + color: #2b6f4b; +} + +.hashtag-option-text { + font-weight: 500; +} + +.hashtag-option-label { + color: #90a4ae; + font-size: 0.8rem; + margin-left: auto; +} + +.date-wrapper { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.date-input { + width: 100%; + padding: 0.7rem; + border-radius: 8px; + border: 1.5px solid #d7e5df; +} + +.date-buttons { + display: flex; + gap: 0.3rem; +} + +.topic-select { + width: 100%; + padding: 0.7rem; + border-radius: 8px; + border: 1.5px solid #d7e5df; + background: white; +} + +.search-submit-btn { + padding: 0.75rem 1.5rem; + font-size: 1rem; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.search-submit-icon { + font-size: 1.1rem; +} + +.results-info-grid { + margin-top: 4rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 3rem; +} + +.info-card { + background: rgba(255, 255, 255, 0.5); + padding: 2rem; + border-radius: 1.5rem; + border: 1px dashed #bcd9cc; +} + +.info-card-title { + font-size: 1.4rem; + color: #1a4a36; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.info-card-icon { + color: #2b6f4b; +} + +.info-card-list { + color: #4a6b78; + line-height: 1.6; + padding-left: 1.2rem; +} + +.info-card-text { + color: #4a6b78; + line-height: 1.6; +} + +.scope-tags { + margin-top: 1rem; + color: #2b6f4b; + font-weight: 500; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.scope-tag { + display: inline-flex; + align-items: center; + gap: 0.2rem; +} + +.scope-tag-icon { + font-size: 1rem; +} +.hero-link { + color: #2b6f4b; +} + +.btn-inline-icon { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.btn-icon { + font-size: 1.1rem; +} + +.sample-title { + margin-bottom: 0.8rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.sample-title-icon { + font-size: 1.1rem; + color: #4a6b78; +} + +.sample-formats { + margin-top: 1.2rem; + font-size: 0.9rem; + display: flex; + gap: 1rem; +} + +.sample-format-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.icon-bar-chart { + font-size: 1rem; + color: #42a5f5; +} + +.icon-tag-green { + font-size: 1rem; + color: #66bb6a; +} + +.feature-card-title { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.icon-public { + color: #2b6f4b; +} + +.icon-tag-orange { + color: #f5a623; +} + +.icon-graph { + color: #42a5f5; +} + +.icon-folder { + color: #ab47bc; +} +.stats-table-wrapper { + overflow-x: auto; + width: 100%; + margin-top: 1rem; + border-radius: 0.7rem; + border: 1px solid var(--border); + background: white; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + text-align: left; + min-width: 800px; +} + +.stats-table thead tr { + background: var(--surface-alt); + border-bottom: 2px solid #d7e5df; + color: var(--text-dark); + font-size: 0.9rem; +} + +.stats-table th { + padding: 1rem; + font-weight: 600; + white-space: nowrap; +} + +.stats-table tbody tr { + border-bottom: 1px solid var(--border); + transition: background 0.15s ease; +} + +.stats-table tbody tr:hover { + background: #f8fafc; +} + +.stats-table td { + padding: 0.8rem 1rem; + color: var(--text); + font-size: 0.9rem; + white-space: nowrap; +} + +.stats-btn-download { + padding: 0.6rem 1.2rem; + background: var(--primary); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-weight: 500; + font-size: 0.95rem; + transition: background 0.2s, transform 0.1s; +} + +.stats-btn-download:hover { + background: var(--primary-dark); + transform: translateY(-1px); +} + +.stats-btn-download:active { + transform: translateY(0); +} + +.stats-page-container { + margin-top: 2rem; + margin-bottom: 6rem; +} + +.stats-search-bar { + margin-bottom: 1.5rem; +} + +.stats-search-form { + margin-bottom: 0; +} +.stats-meta-bar { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + padding: 0.75rem 1.2rem; + background: #eef6f2; + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 1rem; + font-size: 0.9rem; + color: #2d4b5a; +} + +.meta-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.meta-icon { + font-size: 1rem; + color: var(--primary); +} +.table-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.stats-table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1rem 0.5rem 1rem; + flex-wrap: wrap; + gap: 0.75rem; +} + +.stats-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.stats-btn-link { + padding: 0.55rem 1rem; + background: var(--primary-light); + color: var(--primary); + border-radius: 8px; + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 0.3rem; + transition: background 0.15s; +} + +.stats-btn-link:hover { + background: #c9e4d4; +} + +.user-link { + color: var(--primary); + text-decoration: none; + font-weight: 500; +} + +.user-link:hover { + text-decoration: underline; +} + +/* Triple C/M/D columns */ +.triple-col { + white-space: nowrap; + font-size: 0.85rem; +} + +.triple.c { + color: #2e7d32; +} + +.triple.m { + color: #e65100; +} + +.triple.d { + color: #c62828; +} +.sep { + color: #b0bec5; + margin: 0 0.15rem; +} +.map-changes-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: #f0f7f4; + padding: 0.3rem 0.7rem; + border-radius: 2rem; + color: #1a4a36; +} + +.empty-row { + padding: 2rem !important; + text-align: center !important; + color: #90a4ae !important; +} + +.error-banner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + background: var(--error-bg); + border: 1px solid #ef9a9a; + border-radius: 10px; + padding: 1rem 1.2rem; + margin-bottom: 1.5rem; + color: var(--error); +} + +.error-banner .material-icons { + flex-shrink: 0; + font-size: 1.6rem; + margin-top: 0.1rem; +} + +.error-banner p { + margin-top: 0.2rem; + font-size: 0.9rem; + word-break: break-all; +} + +.loading-bar { + display: none; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem; + color: #4a6b78; + font-size: 0.95rem; + background: #f0f7f4; + border-radius: 10px; + margin-bottom: 1rem; +} + +.htmx-indicator.htmx-request { + display: flex !important; +} + +.loading-spin { + width: 20px; + height: 20px; + border: 3px solid #c5e1d3; + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +.page-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.55rem 1.1rem; + background: var(--primary); + color: white; + border-radius: 8px; + text-decoration: none; + font-weight: 500; + font-size: 0.9rem; + transition: background 0.15s; +} + +.page-btn:hover { + background: var(--primary-dark); +} + +.page-info { + color: #4a6b78; + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .stats-table-header { + flex-direction: column; + align-items: flex-start; + } + + .stats-meta-bar { + flex-direction: column; + align-items: flex-start; + } +} \ No newline at end of file diff --git a/osmsg/Frontend/static/js/main.js b/osmsg/Frontend/static/js/main.js new file mode 100644 index 0000000..a274543 --- /dev/null +++ b/osmsg/Frontend/static/js/main.js @@ -0,0 +1,66 @@ +const _fpInstances = {}; + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.date-input[id^="daterange"]').forEach(el => { + _fpInstances[el.id] = flatpickr(el, { + mode: 'range', + dateFormat: 'd-m-Y', + }); + }); +}); + +function setIntervalRangeForId(inputId, type) { + const fp = _fpInstances[inputId]; + if (!fp) return; + const end = new Date(); + const start = new Date(); + if (type === 'daily') start.setDate(end.getDate() - 1); + else if (type === 'weekly') start.setDate(end.getDate() - 7); + else if (type === 'monthly') start.setMonth(end.getMonth() - 1); + fp.setDate([start, end], true); +} + + +function setIntervalRange(type) { + const id = document.getElementById('daterange') ? 'daterange' : 'daterange-stats'; + setIntervalRangeForId(id, type); +} + +function filterHashtags(query, dropdownId) { + const ddId = dropdownId || 'hashtag-dropdown'; + const q = query.toLowerCase(); + const dd = document.getElementById(ddId); + if (!dd) return; + dd.querySelectorAll('.hashtag-option').forEach(item => { + const text = item.querySelector('.hashtag-option-text')?.textContent.toLowerCase() || ''; + const label = item.querySelector('.hashtag-option-label')?.textContent.toLowerCase() || ''; + item.style.display = (text.includes(q) || label.includes(q)) ? 'flex' : 'none'; + }); +} + + +function downloadCSV() { + const table = document.getElementById('leaderboardTable'); + if (!table) return; + + const rows = []; + for (const row of table.rows) { + const cols = []; + for (const cell of row.querySelectorAll('td, th')) { + let text = cell.innerText + .replace(/workspace_premium|add_task/g, '') + .replace(/\s+/g, ' ') + .trim(); + cols.push('"' + text.replace(/"/g, '""') + '"'); + } + rows.push(cols.join(',')); + } + + const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'osmsg_leaderboard.csv'; + link.click(); + URL.revokeObjectURL(url); +} \ No newline at end of file diff --git a/osmsg/Frontend/templates/about.html b/osmsg/Frontend/templates/about.html new file mode 100644 index 0000000..251422e --- /dev/null +++ b/osmsg/Frontend/templates/about.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} {% block title %}About · OSMSG{% endblock %} {% block +content %} + +
+
+
+
On-the-fly · Command-line · OpenStreetMap
+

Stats generator for OSM contributions

+

+ Track changesets, hashtags, countries, and users — instantly. Used by + @stats_osm. +

+ +
+
+ 1.2M+ + commits +
+
+ 6 + output formats +
+
+ + custom periods +
+
+ + +
+ +
+
+ data_object + sample stats output +
+ +
+ { "username": "Mappers", "uid": 148273, "changesets": 1, "nodes.create": + 1071, "nodes.modify": 2100, "ways.create": 146, "ways.modify": 69, + "building.create": 138, "highway.create": 5, "total_map_changes": 3387 } +
+ +
+ + bar_chart + json · csv · excel · image + + + tag + hotosm + +
+
+
+
+ +
+

Why OSMSG ?

+
+
+

+ public + Country / region +

+

+ Use Geofabrik or any replication URL. Extract stats for Nepal, global, + or custom boundaries. +

+
+ +
+

+ tag + Hashtag deep-dive +

+

+ Track #hotosm or any custom hashtag: contributions, users, edits per + tag. +

+
+ +
+

+ auto_graph + Auto charts + summary +

+

+ Generate charts automatically with --charts. Visualizations + ready for socials. +

+
+ +
+

+ folder_copy + Multiple formats +

+

CSV, JSON, Excel, image, text - pipe stats anywhere.

+
+
+
+ +{% endblock %} diff --git a/osmsg/Frontend/templates/base.html b/osmsg/Frontend/templates/base.html new file mode 100644 index 0000000..b075643 --- /dev/null +++ b/osmsg/Frontend/templates/base.html @@ -0,0 +1,28 @@ + + + + + + + {% block title %}OSMSG · OpenStreetMap Stats Generator{% endblock %} + + + + + {% block extra_head %}{% endblock %} + + + + {% include 'components/navbar.html' %} {% block content %}{% endblock %} {% + include 'components/footer.html' %} + + + {% block extra_scripts %}{% endblock %} + + diff --git a/osmsg/Frontend/templates/components/footer.html b/osmsg/Frontend/templates/components/footer.html new file mode 100644 index 0000000..1502a66 --- /dev/null +++ b/osmsg/Frontend/templates/components/footer.html @@ -0,0 +1,19 @@ +
+ + +
\ No newline at end of file diff --git a/osmsg/Frontend/templates/components/navbar.html b/osmsg/Frontend/templates/components/navbar.html new file mode 100644 index 0000000..ab042fb --- /dev/null +++ b/osmsg/Frontend/templates/components/navbar.html @@ -0,0 +1,23 @@ + \ No newline at end of file diff --git a/osmsg/Frontend/templates/contact.html b/osmsg/Frontend/templates/contact.html new file mode 100644 index 0000000..4067330 --- /dev/null +++ b/osmsg/Frontend/templates/contact.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} {% block title %}Contact · OSMSG{% endblock %} {% +block content %} + +
+
+

+ contact_support + Contact Us +

+

+ Have questions about OSM Stats Generator? Reach out to us through any of + the channels below. +

+ +
+
+ mail +
+

Email

+

info@osmsg.project

+
+
+ +
+ alternate_email +
+

Twitter (X)

+

+ @stats_osm +

+
+
+ +
+ code +
+

GitHub

+

+ OsGeoNepal +

+
+
+
+
+
+ +{% endblock %} diff --git a/osmsg/Frontend/templates/index.html b/osmsg/Frontend/templates/index.html new file mode 100644 index 0000000..f0d1f1a --- /dev/null +++ b/osmsg/Frontend/templates/index.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} {% block content %} + +
+
+

+ travel_explore + Search OSM Statistics +

+ +
+ +
+ + +
+ + + expand_more + +
+ {% set popular_hashtags = [ 'OzonGeo', 'tt_event', + 'AfricaMapCup2026', 'maproulette', 'msf', 'missingmaps', 'Aweil', + 'MapComplete', 'tomtom', ] %} {% for hashtag in popular_hashtags %} + +
+ tag + + {{ hashtag }} +
+ + {% endfor %} +
+
+
+ + +
+ +
+ +
+ + + +
+
+
+ + +
+ +
+ +
+
+

+ help_outline + How it works +

+
    +
  1. Enter a hashtag used in OSM changesets.
  2. +
  3. + Select date range or use quick-select (D/W/M). +
  4. +
  5. Choose a topic to filter specific features.
  6. +
  7. Click Get Statistics for instant data.
  8. +
+
+ +
+

+ storage + Scope of Data +

+

+ OSMSG tracks all contributions made to OpenStreetMap through the + standard API. It analyzes labels, comments, and hashtags. +

+
+ + commit Changesets + + + group Mappers + + + edit Edits + +
+
+
+
+
+ +{% endblock %} {% block extra_scripts %} + + +{% endblock %} diff --git a/osmsg/Frontend/templates/partial/table.html b/osmsg/Frontend/templates/partial/table.html new file mode 100644 index 0000000..b079adf --- /dev/null +++ b/osmsg/Frontend/templates/partial/table.html @@ -0,0 +1,142 @@ + + +{% if error %} +
+ error_outline +
+ API Error +

{{ error }}

+
+
+{% else %} + +
+
+ event + {{ meta.start[:10] }} → {{ meta.end[:10] }} +
+ {% if meta.hashtag %} +
+ tag + {{ meta.hashtag[0].replace('#', '') }} +
+ {% endif %} +
+ group + {{ meta.count }} contributors +
+
+ +
+
+

+ emoji_events + Leaderboard +

+
+ + open_in_new + Full Page + + +
+
+ +
+ + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + {% endfor %} {% if not users %} + + + + {% endif %} + +
RankNameChangesetsNodesWaysRelationsPOIMap Changes
+ {% if user.rank == 1 %} + workspace_premium + {% elif user.rank == 2 %} + workspace_premium + {% elif user.rank == 3 %} + workspace_premium + {% else %} + {{ user.rank }} + {% endif %} + + {{ user.name }} + {{ user.changesets }} + {{ user.nodes_create }} + / + {{ user.nodes_modify }} + / + {{ user.nodes_delete }} + + {{ user.ways_create }} + / + {{ user.ways_modify }} + / + {{ user.ways_delete }} + + {{ user.rels_create }} + / + {{ user.rels_modify }} + / + {{ user.rels_delete }} + + {{ user.poi_create }} + / + {{ user.poi_modify }} + + + add_task + {{ "{:,}".format(user.map_changes) }} + +
+ No contributions found for this query. +
+
+
+{% endif %} diff --git a/osmsg/Frontend/templates/statistics.html b/osmsg/Frontend/templates/statistics.html new file mode 100644 index 0000000..f2af419 --- /dev/null +++ b/osmsg/Frontend/templates/statistics.html @@ -0,0 +1,216 @@ +{% extends "base.html" %} {% block title %}Statistics · OSMSG{% endblock %} {% +block content %} + +
+ {% if error %} + +
+ error_outline + +
+ API Error +

{{ error }}

+
+
+ + {% else %} + + +
+
+ event + {{ meta.start[:10] }} → {{ meta.end[:10] }} +
+ + {% if meta.hashtag %} +
+ tag + {{ meta.hashtag[0].replace('#', '') }} +
+ {% endif %} + +
+ group + {{ meta.count }} contributors +
+ +
+ layers + Showing {{ offset + 1 }}–{{ offset + users|length }} +
+
+ + +
+
+

+ emoji_events + Leaderboard +

+ + +
+ +
+ + + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + + + + + + + + + + {% endfor %} {% if not users %} + + + + {% endif %} + +
RankNameChangesetsNodes (C/M/D)Ways (C/M/D)Relations (C/M/D)POI (C/M)Map Changes
+ {% if user.rank == 1 %} + + workspace_premium + + + {% elif user.rank == 2 %} + + workspace_premium + + + {% elif user.rank == 3 %} + + workspace_premium + + + {% else %} + {{ user.rank }} + + {% endif %} + + + {{ user.name }} + + {{ user.changesets }} + {{ user.nodes_create }} + / + {{ user.nodes_modify }} + / + {{ user.nodes_delete }} + + {{ user.ways_create }} + / + {{ user.ways_modify }} + / + {{ user.ways_delete }} + + {{ user.rels_create }} + / + {{ user.rels_modify }} + / + {{ user.rels_delete }} + + {{ user.poi_create }} + / + {{ user.poi_modify }} + + + + add_task + + + {{ "{:,}".format(user.map_changes) }} + +
+ No contributions found for this query. +
+
+
+ {% if users|length == limit or offset > 0 %} + + + + {% endif %} {% endif %} +
+ +{% endblock %} {% block extra_scripts %} + + + + + + + +{% endblock %} From add85eff7e8039069ef367255394768b1e6f86ac Mon Sep 17 00:00:00 2001 From: MadanBelbase Date: Fri, 8 May 2026 18:04:51 +0545 Subject: [PATCH 2/3] refactor: migrate from Flask to Litestar --- osmsg/Frontend/app.py | 250 +++++++++++------- osmsg/Frontend/static/css/style.css | 96 ++++++- osmsg/Frontend/static/js/main.js | 125 ++++++++- .../Frontend/templates/components/navbar.html | 2 +- osmsg/Frontend/templates/index.html | 104 ++------ .../table.html => partials/leaderboard.html} | 4 +- osmsg/Frontend/templates/statistics.html | 2 +- 7 files changed, 386 insertions(+), 197 deletions(-) rename osmsg/Frontend/templates/{partial/table.html => partials/leaderboard.html} (95%) diff --git a/osmsg/Frontend/app.py b/osmsg/Frontend/app.py index 15936a5..0fa0a3c 100644 --- a/osmsg/Frontend/app.py +++ b/osmsg/Frontend/app.py @@ -1,23 +1,28 @@ -from flask import Flask, render_template, request, jsonify -import urllib.request -import urllib.parse +from __future__ import annotations + import json +import urllib.parse +import urllib.request from datetime import datetime, timedelta +from pathlib import Path -app = Flask(__name__) +from litestar import Litestar, get +from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.plugins.htmx import HTMXPlugin, HTMXRequest +from litestar.response import Template +from litestar.static_files import StaticFilesConfig +from litestar.template.config import TemplateConfig API_BASE = "https://osmsg-1.onrender.com/api/v1/user-stats" +BASE_DIR = Path(__file__).parent -@app.context_processor -def inject_globals(): - return { - "current_year": datetime.utcnow().year - } +def _current_year() -> int: + return datetime.utcnow().year -def _parse_dates(daterange_str): +def _parse_dates(daterange_str: str) -> tuple[str, str]: now = datetime.utcnow() yesterday = now - timedelta(days=1) @@ -32,10 +37,14 @@ def _parse_dates(daterange_str): try: if "to" in daterange_str: left, right = daterange_str.split("to", 1) + d1 = datetime.strptime(left.strip(), "%d-%m-%Y") d2 = datetime.strptime(right.strip(), "%d-%m-%Y") else: - d1 = d2 = datetime.strptime(daterange_str.strip(), "%d-%m-%Y") + d1 = d2 = datetime.strptime( + daterange_str.strip(), + "%d-%m-%Y", + ) return ( d1.strftime("%Y-%m-%dT00:00:00Z"), @@ -46,131 +55,172 @@ def _parse_dates(daterange_str): return fallback -def _fetch(params): - - url = f"{API_BASE}?{urllib.parse.urlencode(params)}" +def _fetch(params: dict) -> dict: + url = f"{API_BASE}?{urllib.parse.urlencode(params, doseq=True)}" req = urllib.request.Request( url, - headers={"Accept": "application/json"} + headers={"Accept": "application/json"}, ) - with urllib.request.urlopen(req, timeout=90) as resp: - raw = resp.read().decode() + with urllib.request.urlopen(req, timeout=90) as response: + raw = response.read().decode() + if not raw.strip(): return {} + return json.loads(raw) +def _url_for(endpoint: str, **values: str) -> str: + if endpoint == "static": + filename = values.get("filename", "") + return f"/static/{filename}" + + return f"/{endpoint}" + -@app.route("/") -def index(): - return render_template("index.html") +@get("/") +async def index(request: HTMXRequest) -> Template: + return Template( + "index.html", + context={ + "current_year": _current_year(), + }, + ) -@app.route("/statistics") -def statistics(): - hashtag = request.args.get("hashtag", "").strip() - daterange = request.args.get("daterange", "").strip() - limit = int(request.args.get("limit", 25)) - offset = int(request.args.get("offset", 0)) +@get("/statistics") +async def statistics( + request: HTMXRequest, + hashtags: list[str] | None = None, + daterange: str = "", + limit: int = 25, + offset: int = 0, +) -> Template: start, end = _parse_dates(daterange) params = { - "start": start, - "end": end, - "limit": limit, + "start": start, + "end": end, + "limit": limit, "offset": offset, } - if hashtag: - params["hashtag"] = hashtag + if hashtags: + params["hashtag"] = ",".join( + f"#{tag.lstrip('#')}" + for tag in hashtags + ) - try: - raw = _fetch(params) - users = raw.get("users", []) - count = raw.get("count", len(users)) - meta = { - "start": raw.get("start", start), - "end": raw.get("end", end), - "hashtag": raw.get("hashtag", hashtag), - "limit": raw.get("limit", limit), - "offset": raw.get("offset", offset), - "count": count, - } - error = None - - except Exception as exc: - users = [] - meta = { - "start": start, - "end": end, - "hashtag": hashtag, - "limit": limit, - "offset": offset, - "count": 0, - } - error = str(exc) - - is_htmx = request.headers.get("HX-Request") == "true" - template = "partial/table.html" if is_htmx else "statistics.html" - - return render_template( - template, - hashtag=hashtag, - daterange=daterange, - users=users, - meta=meta, - error=error, - limit=limit, - offset=offset, + raw = _fetch(params) + + print(params) + print(raw) + + users = raw.get("users", []) + + meta = { + "start": raw.get("start", start), + "end": raw.get("end", end), + "count": raw.get("count", len(users)), + "hashtag": raw.get("hashtag", hashtags or []), + "limit": raw.get("limit", limit), + "offset": raw.get("offset", offset), + } + + template = ( + "partials/leaderboard.html" + if request.htmx + else "statistics.html" ) + return Template( + template, + context={ + "users": users, + "meta": meta, + "hashtags": hashtags or [], + "daterange": daterange, + "limit": limit, + "offset": offset, + "current_year": _current_year(), + }, + ) -@app.route("/api/proxy") -def api_proxy(): - hashtag = request.args.get("hashtag", "").strip() - daterange = request.args.get("daterange", "").strip() - limit = int(request.args.get("limit", 25)) - offset = int(request.args.get("offset", 0)) +@get("/api/proxy") +async def api_proxy( + request: HTMXRequest, + hashtags: list[str] | None = None, + daterange: str = "", + limit: int = 25, + offset: int = 0, +) -> dict: start, end = _parse_dates(daterange) params = { - "start": start, - "end": end, - "limit": limit, + "start": start, + "end": end, + "limit": limit, "offset": offset, } - if hashtag: - params["hashtag"] = hashtag - - try: - return jsonify(_fetch(params)) - - except urllib.error.HTTPError as exc: - body = exc.read().decode(errors="replace") - return jsonify({"error": f"HTTP {exc.code}", "detail": body}), - - except urllib.error.URLError as exc: - return jsonify({"error": "Upstream unreachable", "detail": str(exc.reason)}), + if hashtags: + params["hashtag"] = ",".join( + f"#{tag.lstrip('#')}" + for tag in hashtags + ) - except Exception as exc: - return jsonify({"error": str(exc)}) + return _fetch(params) -@app.route("/about") -def about(): - return render_template("about.html") +@get("/about") +async def about(request: HTMXRequest) -> Template: + return Template( + "about.html", + context={ + "current_year": _current_year(), + }, + ) -@app.route("/contact") -def contact(): - return render_template("contact.html") +@get("/contact") +async def contact(request: HTMXRequest) -> Template: + return Template( + "contact.html", + context={ + "current_year": _current_year(), + }, + ) -if __name__ == "__main__": - app.run(debug=True, port=5004) \ No newline at end of file +def _configure_jinja(engine: JinjaTemplateEngine) -> None: + engine.engine.globals["url_for"] = _url_for + + +app = Litestar( + route_handlers=[ + index, + statistics, + api_proxy, + about, + contact, + ], + plugins=[HTMXPlugin()], + template_config=TemplateConfig( + directory=BASE_DIR / "templates", + engine=JinjaTemplateEngine, + engine_callback=_configure_jinja, + ), + static_files_config=[ + StaticFilesConfig( + directories=[BASE_DIR / "static"], + path="/static", + name="static", + ) + ], + debug=True, +) \ No newline at end of file diff --git a/osmsg/Frontend/static/css/style.css b/osmsg/Frontend/static/css/style.css index 59b6cb1..5bb50b2 100644 --- a/osmsg/Frontend/static/css/style.css +++ b/osmsg/Frontend/static/css/style.css @@ -264,9 +264,8 @@ body { .horizontal-form { display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 1rem; + flex-direction: column; + gap: 0; margin: 0 0 2rem 0; padding: 1.5rem; background: white; @@ -275,11 +274,20 @@ body { box-shadow: 0 10px 25px -10px rgba(0, 0, 0, 0.08); } +.form-inputs-row { + display: flex; + flex-wrap: nowrap; + align-items: flex-end; + gap: 1rem; + width: 100%; +} + .form-group { display: flex; flex-direction: column; flex: 1 1 180px; min-width: 140px; + position: relative; } .form-group label { @@ -306,6 +314,27 @@ body { box-shadow: 0 0 0 3px rgba(43, 111, 75, 0.1); } +.form-inputs-row .form-group:first-child { + flex: 0 0 380px; +} + +.form-inputs-row .form-group:nth-child(2) { + flex: 1; +} + +.tag-chip-box { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + min-height: 0; + transition: all 0.2s ease; +} + +.tag-chip-box:empty { + margin-top: 0; +} + .search-btn { background: #2b6f4b; color: white; @@ -323,6 +352,11 @@ body { background: #1d543a; } +.search-submit-btn { + flex-shrink: 0; + align-self: flex-end; +} + /* Feature Section */ .feature-section { max-width: 1280px; @@ -895,9 +929,61 @@ body { font-size: 0.8rem; margin-left: auto; } +g-chip-box { + position: absolute; + top: 100%; + left: 0; + margin-top: 6px; + z-index: 10; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: #e1f5ee; + color: #0f6e56; + border: 1px solid #9fe1cb; + border-radius: 20px; + padding: 4px 12px; + font-size: 0.85rem; + font-weight: 500; +} + +.tag-chip .remove-chip { + cursor: pointer; + font-size: 1rem; + color: #0f6e56; + line-height: 1; +} + +.tag-chip .remove-chip:hover { + color: #d85a30; +} + +.tag-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ddd; + max-height: 200px; + overflow-y: auto; + z-index: 1000; +} + +.suggestion-item { + padding: 8px; + cursor: pointer; +} + +.suggestion-item:hover { + background: #f0f0f0; +} .date-wrapper { - display: flex; + display:flex ; gap: 0.5rem; align-items: center; } @@ -929,6 +1015,8 @@ body { display: inline-flex; align-items: center; gap: 0.4rem; + margin-top: 22px; + flex-shrink: 0; } .search-submit-icon { diff --git a/osmsg/Frontend/static/js/main.js b/osmsg/Frontend/static/js/main.js index a274543..a1f3360 100644 --- a/osmsg/Frontend/static/js/main.js +++ b/osmsg/Frontend/static/js/main.js @@ -26,19 +26,6 @@ function setIntervalRange(type) { setIntervalRangeForId(id, type); } -function filterHashtags(query, dropdownId) { - const ddId = dropdownId || 'hashtag-dropdown'; - const q = query.toLowerCase(); - const dd = document.getElementById(ddId); - if (!dd) return; - dd.querySelectorAll('.hashtag-option').forEach(item => { - const text = item.querySelector('.hashtag-option-text')?.textContent.toLowerCase() || ''; - const label = item.querySelector('.hashtag-option-label')?.textContent.toLowerCase() || ''; - item.style.display = (text.includes(q) || label.includes(q)) ? 'flex' : 'none'; - }); -} - - function downloadCSV() { const table = document.getElementById('leaderboardTable'); if (!table) return; @@ -63,4 +50,114 @@ function downloadCSV() { link.download = 'osmsg_leaderboard.csv'; link.click(); URL.revokeObjectURL(url); -} \ No newline at end of file +} + +const TAGS = [ + "OzonGeo", + "tt_event", + "AfricaMapCup2026", + "maproulette", + "msf", + "missingmaps", + "Aweil", + "MapComplete", + "homtom", + "osm-tr", + "hotosm-project-49902", + "GeoTETZ", + "OSMTanzania", + "hotosm-project-49935", + "youthmappersoau", + "hotosm-project-49638", + "osmzwawakening", + "syria-remapping-2025", + "Kaart", + "osmzimbabwe" +]; + +const input = document.getElementById("hashtag-input"); +const suggestionsBox = document.getElementById("tag-suggestions"); +const chipBox = document.getElementById("tag-chip-box"); +const hiddenBox = document.getElementById("hashtag-hiddens"); + +let tags = []; + +function renderTags() { + chipBox.innerHTML = ""; + hiddenBox.innerHTML = ""; + + tags.forEach((tag, i) => { + const chip = document.createElement("span"); + chip.className = "tag-chip"; + chip.innerHTML = ` + ${tag} + × + `; + + chipBox.appendChild(chip); + + const hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.name = "hashtags"; + hidden.value = tag; + + hiddenBox.appendChild(hidden); + }); +} + +function showSuggestions(value) { + suggestionsBox.innerHTML = ""; + + if (!value) return; + + const filtered = TAGS.filter( + t => + t.toLowerCase().includes(value.toLowerCase()) && + !tags.includes(t) + ); + + filtered.forEach(tag => { + const item = document.createElement("div"); + item.className = "suggestion-item"; + item.textContent = tag; + + item.onclick = () => { + tags.push(tag); + renderTags(); + + input.value = ""; + suggestionsBox.innerHTML = ""; + }; + + suggestionsBox.appendChild(item); + }); +} + +input.addEventListener("input", (e) => { + showSuggestions(e.target.value); +}); + + +input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + + const value = input.value.trim(); + + if (value && TAGS.includes(value) && !tags.includes(value)) { + tags.push(value); + renderTags(); + } + + input.value = ""; + suggestionsBox.innerHTML = ""; + } +}); + +chipBox.addEventListener("click", (e) => { + if (e.target.classList.contains("tag-remove")) { + const index = e.target.dataset.index; + tags.splice(index, 1); + renderTags(); + } +}); \ No newline at end of file diff --git a/osmsg/Frontend/templates/components/navbar.html b/osmsg/Frontend/templates/components/navbar.html index ab042fb..c706252 100644 --- a/osmsg/Frontend/templates/components/navbar.html +++ b/osmsg/Frontend/templates/components/navbar.html @@ -3,7 +3,7 @@
OSM
OSMSG
-
v0.3.0
+
v1.0.0