From e14ec4799023ed245abae5acd4e7dee4b29dae2e Mon Sep 17 00:00:00 2001 From: bramval Date: Thu, 29 Jan 2026 16:44:30 +0100 Subject: [PATCH] Initial commit: RSS feed viewer with Docker setup --- .dockerignore | 12 + .gitignore | 25 + Dockerfile | 18 + README.md | 57 +++ app.py | 137 ++++++ docker-compose.yml | 12 + requirements.txt | 3 + static/app.js | 155 ++++++ static/logo-placeholder.svg | 958 ++++++++++++++++++++++++++++++++++++ static/styles.css | 189 +++++++ templates/index.html | 52 ++ 11 files changed, 1618 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/logo-placeholder.svg create mode 100644 static/styles.css create mode 100644 templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fabd864 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.ruff_cache +node_modules +dist +build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2630c97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Python tooling caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Editor/OS files +.vscode/ +.idea/ +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37b0b05 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +# Prevent Python from writing .pyc files and enable unbuffered logs. +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Install dependencies first for better layer caching. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +# Keep testing and local dev via `flask` if desired; docker runs the app directly. +CMD ["python", "app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa41b9e --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# RSS Newsfeed Viewer (Flask) + +Responsive RSS viewer that shows **headlines slide-by-slide** (latest **5** items), with a placeholder logo, feed title, and subtle background + slide animations. + +## Run locally + +```bat +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +python app.py +``` + +Open: http://127.0.0.1:5000 + +### Optional: choose a different feed + +```text +http://127.0.0.1:5000/?url=https%3A%2F%2Fexample.com%2Frss.xml +``` + +Or set an env var: + +```bat +set RSS_URL=https://example.com/rss.xml +python app.py +``` + +### Optional: set a custom logo + +By default a bundled placeholder logo is used. To override it: + +```bat +set LOGO_URL=https://example.com/logo.png +python app.py +``` + +## Run with Docker Compose + +Edit `docker-compose.yml` and adjust: + +- `RSS_URL` (RSS feed) +- `LOGO_URL` (logo image URL) + +Then run: + +```bash +docker compose up --build +``` + +Open: http://127.0.0.1:5000 + +## Controls + +- Click/tap: next headline +- Left/right arrow keys: previous/next +- Space: pause/resume diff --git a/app.py b/app.py new file mode 100644 index 0000000..2621e6a --- /dev/null +++ b/app.py @@ -0,0 +1,137 @@ +import os +import time +from urllib.parse import urlparse + +import feedparser +import requests +from flask import Flask, jsonify, render_template, request + + +DEFAULT_RSS_URL = os.environ.get( + "RSS_URL", "https://feeds.nos.nl/nosnieuwsalgemeen" +) + +DEFAULT_LOGO_URL = os.environ.get("LOGO_URL", "") + + +def create_app() -> Flask: + app = Flask(__name__) + + # Very small in-memory cache (good enough for a single-process demo). + # For production: swap this with Redis/Memcached. + cache: dict[str, tuple[float, dict]] = {} + cache_ttl_seconds = int(os.environ.get("RSS_CACHE_TTL", "60")) + + def is_valid_url(url: str) -> bool: + try: + parsed = urlparse(url) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + except Exception: + return False + + def fetch_feed(url: str) -> dict: + now = time.time() + cached = cache.get(url) + if cached and now - cached[0] < cache_ttl_seconds: + return cached[1] + + headers = { + "User-Agent": "newsfeed-viewer/1.0 (+Flask; feedparser)" + } + resp = requests.get(url, headers=headers, timeout=8) + resp.raise_for_status() + + parsed = feedparser.parse(resp.content) + feed_title = (parsed.feed.get("title") or "News").strip() + + def entry_ts(e) -> int: + # Prefer published_parsed, fallback to updated_parsed, else 0. + st = getattr(e, "published_parsed", None) or getattr( + e, "updated_parsed", None + ) + if not st: + return 0 + return int(time.mktime(st)) + + # Sort newest first when we have dates; otherwise keep original order. + entries = list(parsed.entries or []) + if any(entry_ts(e) for e in entries): + entries.sort(key=entry_ts, reverse=True) + + items = [] + for e in entries[:5]: + # Try to extract a hero/background image from RSS/Atom enclosure/media fields. + enclosure_url = None + try: + links = getattr(e, "links", None) or [] + for l in links: + if (l.get("rel") == "enclosure") and (l.get("href")): + enclosure_url = l.get("href") + break + except Exception: + enclosure_url = None + + if not enclosure_url: + try: + media = getattr(e, "media_content", None) or getattr( + e, "media_thumbnail", None + ) + if media and isinstance(media, list) and media[0].get("url"): + enclosure_url = media[0].get("url") + except Exception: + enclosure_url = None + + items.append( + { + "title": (getattr(e, "title", "") or "").strip(), + "link": getattr(e, "link", None), + "published": getattr(e, "published", None) + or getattr(e, "updated", None), + "timestamp": entry_ts(e), + "enclosure_url": enclosure_url, + } + ) + + data = { + "url": url, + "title": feed_title, + "items": items, + "fetched_at": int(now), + } + cache[url] = (now, data) + return data + + @app.get("/") + def index(): + rss_url = request.args.get("url") or DEFAULT_RSS_URL + # Allow docker-compose (or any env) to override the logo without changing code. + logo_url = os.environ.get("LOGO_URL", DEFAULT_LOGO_URL).strip() + return render_template("index.html", rss_url=rss_url, logo_url=logo_url) + + @app.get("/api/feed") + def api_feed(): + url = request.args.get("url") or DEFAULT_RSS_URL + if not is_valid_url(url): + return jsonify({"error": "Invalid url"}), 400 + try: + return jsonify(fetch_feed(url)) + except requests.RequestException as e: + return ( + jsonify( + { + "error": "Failed to fetch RSS feed", + "detail": str(e), + } + ), + 502, + ) + except Exception as e: + return jsonify({"error": "Failed to parse RSS feed", "detail": str(e)}), 500 + + return app + + +if __name__ == "__main__": + app = create_app() + # For production: use a proper WSGI server (gunicorn/uwsgi). + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "5000")), debug=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..659d54b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + web: + build: . + ports: + - "5000:5000" + environment: + # Edit these in the docker compose editor to change the feed/logo. + RSS_URL: "https://feeds.nos.nl/nosnieuwsalgemeen" + LOGO_URL: "" + # Optional: tweak cache TTL (seconds) + RSS_CACHE_TTL: "60" + PORT: "5000" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9d3c20b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +feedparser==6.0.11 +requests==2.32.3 \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..2a685c3 --- /dev/null +++ b/static/app.js @@ -0,0 +1,155 @@ +const rssUrl = window.__RSS_URL__; + +const els = { + feedTitle: document.getElementById('feedTitle'), + headlineText: document.getElementById('headlineText'), + headlineLink: document.getElementById('headlineLink'), + clock: document.getElementById('clock'), + articleTime: document.getElementById('articleTime'), + progressFill: document.getElementById('progressFill'), + bg: document.getElementById('bg'), +}; + +let items = []; +let idx = 0; +let timer = null; +let progressTimer = null; +let paused = false; + +const SLIDE_MS = 6000; + +function fmtClock(d) { + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function tickClock() { + els.clock.textContent = fmtClock(new Date()); +} + +function resetProgress() { + if (progressTimer) { + cancelAnimationFrame(progressTimer); + progressTimer = null; + } + const start = performance.now(); + els.progressFill.style.width = '0%'; + + const loop = (t) => { + if (paused) return; + const p = Math.min(1, (t - start) / SLIDE_MS); + els.progressFill.style.width = `${(p * 100).toFixed(2)}%`; + if (p < 1) progressTimer = requestAnimationFrame(loop); + }; + progressTimer = requestAnimationFrame(loop); +} + +function setBackground(item) { + if (!els.bg) return; + const url = item?.enclosure_url; + if (url) { + // Include the same gradient fallbacks as the CSS, but insert the image layer. + // NOTE: No dark overlay on top of the image (requested). + els.bg.style.backgroundImage = [ + `url("${url}")`, + 'radial-gradient(1200px 600px at 70% 20%, rgba(100, 150, 255, 0.35), transparent 55%)', + 'radial-gradient(900px 500px at 20% 70%, rgba(20, 255, 190, 0.16), transparent 55%)', + 'linear-gradient(130deg, var(--bg1), var(--bg2))', + ].join(', '); + } else { + // Reset to stylesheet default. + els.bg.style.backgroundImage = ''; + } +} + +function setHeadline(i, animate = true) { + if (!items.length) return; + const item = items[i]; + + setBackground(item); + + const node = els.headlineText; + + if (animate) { + node.classList.remove('slide-in'); + node.classList.add('slide-out'); + setTimeout(() => { + node.textContent = item.title || '(untitled)'; + els.headlineLink.href = item.link || '#'; + if (els.articleTime) els.articleTime.textContent = item.published || '—'; + node.classList.remove('slide-out'); + node.classList.add('slide-in'); + }, 280); + } else { + node.textContent = item.title || '(untitled)'; + els.headlineLink.href = item.link || '#'; + node.classList.add('slide-in'); + } + + resetProgress(); +} + +function next() { + if (!items.length) return; + idx = (idx + 1) % items.length; + setHeadline(idx); +} + +function prev() { + if (!items.length) return; + idx = (idx - 1 + items.length) % items.length; + setHeadline(idx); +} + +function startAuto() { + stopAuto(); + timer = setInterval(() => { + if (!paused) next(); + }, SLIDE_MS); +} + +function stopAuto() { + if (timer) clearInterval(timer); + timer = null; +} + +function togglePause() { + paused = !paused; + if (!paused) resetProgress(); +} + +async function loadFeed() { + els.feedTitle.textContent = 'Loading…'; + els.headlineText.textContent = 'Loading headlines…'; + + const apiUrl = `/api/feed?url=${encodeURIComponent(rssUrl)}`; + const r = await fetch(apiUrl, { cache: 'no-store' }); + const data = await r.json(); + if (!r.ok) throw new Error(data?.error || 'Failed to load feed'); + + els.feedTitle.textContent = data.title || 'News'; + items = (data.items || []).filter((x) => (x.title || '').trim().length > 0).slice(0, 5); + if (!items.length) { + els.headlineText.textContent = 'No items found.'; + return; + } + idx = 0; + setHeadline(idx, false); + startAuto(); +} + +function wireControls() { + // Removed manual navigation controls. This is now a passive slideshow. +} + +(async function init() { + tickClock(); + setInterval(tickClock, 1000); + wireControls(); + try { + await loadFeed(); + } catch (e) { + els.feedTitle.textContent = 'Error'; + els.headlineText.textContent = e?.message || 'Failed to load.'; + } +})(); diff --git a/static/logo-placeholder.svg b/static/logo-placeholder.svg new file mode 100644 index 0000000..aea0bfb --- /dev/null +++ b/static/logo-placeholder.svg @@ -0,0 +1,958 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..3a26702 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,189 @@ +:root { + --bg1: #0d1321; + --bg2: #1d2d50; + --accent: #1f3b98; + --panel: rgba(12, 18, 32, 0.55); + --panel-2: rgba(10, 12, 22, 0.72); + --text: #eef2ff; + --muted: rgba(238, 242, 255, 0.7); + --headline-font: clamp(18px, 2.4vw, 40px); +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Helvetica Neue", sans-serif; + color: var(--text); + overflow: hidden; + background: #070b14; +} + +.stage { + position: relative; + height: 100vh; + width: 100vw; + display: grid; + grid-template-rows: auto 1fr auto; +} + +.bg { + position: absolute; + inset: 0; + background: + /* Dark overlay for readability */ + /*linear-gradient(180deg, rgba(0,0,0,0.55), rgba(0,0,0,0.55)),*/ + /* Default/fallback background */ + radial-gradient(1200px 600px at 70% 20%, rgba(100, 150, 255, 0.35), transparent 55%), + radial-gradient(900px 500px at 20% 70%, rgba(20, 255, 190, 0.16), transparent 55%), + linear-gradient(130deg, var(--bg1), var(--bg2)); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + transform: scale(1.02); + filter: saturate(1.2) contrast(1.05); + animation: bgFloat 18s ease-in-out infinite; +} + +@keyframes bgFloat { + 0%, 100% { transform: scale(1.03) translate3d(0, 0, 0); } + 50% { transform: scale(1.06) translate3d(-1.2%, -0.8%, 0); } +} + +.feed-header { + position: relative; + z-index: 2; + padding: clamp(16px, 3vw, 32px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 14px; + background: rgba(255, 255, 255, 0.92); + color: #0b1020; + border-radius: 10px; + padding: 10px 14px; + box-shadow: 0 14px 40px rgba(0,0,0,0.35); + transform-origin: left center; + animation: popIn 600ms cubic-bezier(.2,.9,.2,1) both; +} + +@keyframes popIn { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.brand__logo { + width: 48px; + height: 48px; +} + +.brand__title { + font-weight: 700; + font-size: 22px; + letter-spacing: 0.2px; +} + +.headline { + position: relative; + z-index: 2; + align-self: end; + margin: 0 clamp(12px, 3vw, 32px) clamp(56px, 8vh, 96px); + max-width: min(1100px, 94vw); +} + +.headline__link { + display: block; + text-decoration: none; + color: var(--text); + background: linear-gradient(90deg, rgba(9, 14, 30, 0.0), rgba(12, 18, 40, 0.86) 12%, rgba(12, 18, 40, 0.86) 88%, rgba(9, 14, 30, 0.0)); + border-left: 8px solid rgba(66, 120, 255, 0.85); + padding: clamp(14px, 2vw, 22px) clamp(14px, 2.6vw, 28px); + box-shadow: 0 18px 60px rgba(0,0,0,0.45); + backdrop-filter: blur(10px); +} + +.headline__text { + display: block; + font-size: var(--headline-font); + line-height: 1.15; + letter-spacing: 0.2px; + text-wrap: balance; +} + +.headline__progress { + height: 3px; + background: rgba(255,255,255,0.18); + margin-top: 10px; + border-radius: 999px; + overflow: hidden; +} + +.headline__progressFill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, rgba(90, 160, 255, 0.9), rgba(20, 255, 200, 0.75)); +} + +.meta { + position: relative; + z-index: 2; + display: flex; + justify-content: space-between; + align-items: end; + gap: 12px; + padding: 16px clamp(12px, 3vw, 32px); +} + +.meta__time { + font-variant-numeric: tabular-nums; + background: rgba(12, 18, 40, 0.85); + padding: 8px 12px; + border-radius: 8px; + box-shadow: 0 12px 34px rgba(0,0,0,0.35); + border: 1px solid rgba(255,255,255,0.14); + display: inline-flex; + flex-direction: column; + gap: 2px; +} + +.meta__hint { + color: var(--muted); + font-size: 12px; + user-select: none; +} + +/* Slide animation states (toggled by JS) */ +.slide-out { + animation: slideOut 360ms ease both; +} +.slide-in { + animation: slideIn 520ms cubic-bezier(.16,.9,.2,1) both; +} + +@keyframes slideOut { + from { opacity: 1; transform: translateX(0); filter: blur(0); } + to { opacity: 0; transform: translateX(-16px); filter: blur(2px); } +} + +@keyframes slideIn { + from { opacity: 0; transform: translateX(18px); filter: blur(3px); } + to { opacity: 1; transform: translateX(0); filter: blur(0); } +} + +@media (max-width: 560px) { + .brand__title { font-size: 18px; } + .brand__logo { width: 40px; height: 40px; } + .meta__hint { display: none; } + .headline { margin-bottom: 64px; } +} + +.meta__timePrimary { + font-size: 14px; +} + +.meta__timeSecondary { + font-size: 12px; + color: var(--muted); +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b75f0a5 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,52 @@ + + + + + + RSS Newsfeed Viewer + + + +
+ + +
+
+ +
Loading…
+
+
+ +
+ + Loading headlines… + + +
+ +
+
+ + +
+
+
+ + + + +