From ec9f31f07222e533dc3bc13526903a062e6ed846 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 6 Apr 2026 15:21:18 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20web=20UI=20=E2=80=94=20dashb?= =?UTF-8?q?oard,=20articles,=20publish,=20settings,=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- src/routes/__init__.py | 13 +++++ src/routes/articles.py | 48 ++++++++++++++++++ src/routes/dashboard.py | 47 ++++++++++++++++++ src/routes/issues.py | 77 +++++++++++++++++++++++++++++ src/routes/publish.py | 102 +++++++++++++++++++++++++++++++++++++++ src/routes/settings.py | 61 +++++++++++++++++++++++ static/style.css | 38 +++++++++++++++ templates/articles.html | 48 ++++++++++++++++++ templates/base.html | 40 +++++++++++++++ templates/dashboard.html | 46 ++++++++++++++++++ templates/issues.html | 44 +++++++++++++++++ templates/publish.html | 75 ++++++++++++++++++++++++++++ templates/settings.html | 79 ++++++++++++++++++++++++++++++ 13 files changed, 718 insertions(+) create mode 100644 src/routes/__init__.py create mode 100644 src/routes/articles.py create mode 100644 src/routes/dashboard.py create mode 100644 src/routes/issues.py create mode 100644 src/routes/publish.py create mode 100644 src/routes/settings.py create mode 100644 static/style.css create mode 100644 templates/articles.html create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/issues.html create mode 100644 templates/publish.html create mode 100644 templates/settings.html diff --git a/src/routes/__init__.py b/src/routes/__init__.py new file mode 100644 index 0000000..be1ba38 --- /dev/null +++ b/src/routes/__init__.py @@ -0,0 +1,13 @@ +from src.routes.dashboard import dashboard_bp +from src.routes.articles import articles_bp +from src.routes.publish import publish_bp +from src.routes.settings import settings_bp +from src.routes.issues import issues_bp + + +def register_blueprints(app): + app.register_blueprint(dashboard_bp) + app.register_blueprint(articles_bp) + app.register_blueprint(publish_bp) + app.register_blueprint(settings_bp) + app.register_blueprint(issues_bp) diff --git a/src/routes/articles.py b/src/routes/articles.py new file mode 100644 index 0000000..53a42b3 --- /dev/null +++ b/src/routes/articles.py @@ -0,0 +1,48 @@ +import json +from datetime import date, timedelta +from flask import Blueprint, render_template, request + +from src.models import Article + +articles_bp = Blueprint("articles", __name__) + + +@articles_bp.route("/articles") +def index(): + week_filter = request.args.get("week") + category_filter = request.args.get("category") + + query = Article.query + + if week_filter: + try: + year, week_num = week_filter.split("-W") + week_start = date.fromisocalendar(int(year), int(week_num), 1) + week_end = week_start + timedelta(days=6) + query = query.filter( + Article.pub_date >= str(week_start), + Article.pub_date < str(week_end + timedelta(days=1)), + ) + except (ValueError, TypeError): + pass + + articles = query.order_by(Article.pub_date.desc()).all() + + if category_filter: + articles = [ + a for a in articles + if category_filter in json.loads(a.categories) + ] + + all_categories = set() + for a in Article.query.all(): + for cat in json.loads(a.categories): + all_categories.add(cat) + + return render_template( + "articles.html", + articles=articles, + categories=sorted(all_categories), + week_filter=week_filter or "", + category_filter=category_filter or "", + ) diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py new file mode 100644 index 0000000..f723814 --- /dev/null +++ b/src/routes/dashboard.py @@ -0,0 +1,47 @@ +from datetime import date, timedelta +from flask import Blueprint, render_template, redirect, url_for, flash + +from app import db +from src.models import Article, Issue + +dashboard_bp = Blueprint("dashboard", __name__) + + +@dashboard_bp.route("/") +def index(): + today = date.today() + week_start = today - timedelta(days=today.weekday()) + week_end = week_start + timedelta(days=6) + + articles_this_week = Article.query.filter( + Article.pub_date >= str(week_start), + Article.pub_date < str(week_end + timedelta(days=1)), + ).count() + + total_articles = Article.query.count() + total_issues = Issue.query.count() + latest_issue = Issue.query.order_by(Issue.created_at.desc()).first() + + from flask import current_app + scheduler_mgr = current_app.config.get("SCHEDULER_MANAGER") + scheduler_status = scheduler_mgr.get_status() if scheduler_mgr else {"running": False} + + return render_template( + "dashboard.html", + articles_this_week=articles_this_week, + total_articles=total_articles, + total_issues=total_issues, + latest_issue=latest_issue, + scheduler_status=scheduler_status, + ) + + +@dashboard_bp.route("/fetch-now", methods=["POST"]) +def fetch_now(): + from src.fetcher import fetch_and_cache_articles + result = fetch_and_cache_articles() + if result.get("error"): + flash(f"Fetch error: {result['error']}", "error") + else: + flash(f"Fetched {result['new']} new articles, {result['skipped']} skipped.") + return redirect(url_for("dashboard.index")) diff --git a/src/routes/issues.py b/src/routes/issues.py new file mode 100644 index 0000000..5b905c4 --- /dev/null +++ b/src/routes/issues.py @@ -0,0 +1,77 @@ +import os +import json +from flask import Blueprint, render_template, send_file, redirect, url_for, flash + +from app import db +from src.models import Issue, Article +from src.cover import generate_cover +from src.epub_builder import build_epub +import config + +issues_bp = Blueprint("issues", __name__) + + +@issues_bp.route("/issues") +def index(): + issues = Issue.query.order_by(Issue.created_at.desc()).all() + issue_data = [] + for issue in issues: + article_count = len(json.loads(issue.article_ids)) + issue_data.append({ + "issue": issue, + "article_count": article_count, + }) + return render_template("issues.html", issues=issue_data) + + +@issues_bp.route("/issues//download") +def download(issue_id): + issue = Issue.query.get_or_404(issue_id) + if not os.path.exists(issue.epub_path): + flash("ePub file not found.", "error") + return redirect(url_for("issues.index")) + return send_file( + issue.epub_path, + as_attachment=True, + download_name=os.path.basename(issue.epub_path), + ) + + +@issues_bp.route("/issues//cover") +def cover_image(issue_id): + issue = Issue.query.get_or_404(issue_id) + if not issue.cover_path or not os.path.exists(issue.cover_path): + flash("Cover image not found.", "error") + return redirect(url_for("issues.index")) + return send_file(issue.cover_path, mimetype="image/jpeg") + + +@issues_bp.route("/issues//regenerate", methods=["POST"]) +def regenerate(issue_id): + issue = Issue.query.get_or_404(issue_id) + article_ids = json.loads(issue.article_ids) + + headlines = [ + a.title for a in Article.query.filter(Article.id.in_(article_ids)) + .order_by(Article.pub_date.asc()).all() + ] + + try: + cover_path = generate_cover( + issue.cover_method, config.ISSUES_DIR, + issue.week_start, issue.week_end, headlines + ) + epub_path = build_epub( + issue.week_start, issue.week_end, article_ids, + cover_path, config.ISSUES_DIR + ) + + issue.cover_path = cover_path + issue.epub_path = epub_path + db.session.commit() + + flash("Issue regenerated successfully.") + except Exception as e: + flash(f"Regeneration failed: {e}", "error") + + return redirect(url_for("issues.index")) diff --git a/src/routes/publish.py b/src/routes/publish.py new file mode 100644 index 0000000..50d1144 --- /dev/null +++ b/src/routes/publish.py @@ -0,0 +1,102 @@ +import json +from datetime import date, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash + +from app import db +from src.models import Article, Issue +from src.cover import generate_cover +from src.epub_builder import build_epub +import config + +publish_bp = Blueprint("publish", __name__) + + +@publish_bp.route("/publish", methods=["GET"]) +def index(): + week_str = request.args.get("week") + if week_str: + try: + year, week_num = week_str.split("-W") + week_start = date.fromisocalendar(int(year), int(week_num), 1) + except (ValueError, TypeError): + week_start = date.today() - timedelta(days=date.today().weekday()) + else: + week_start = date.today() - timedelta(days=date.today().weekday()) + + week_end = week_start + timedelta(days=6) + + articles = ( + Article.query + .filter( + Article.pub_date >= str(week_start), + Article.pub_date < str(week_end + timedelta(days=1)), + ) + .order_by(Article.pub_date.asc()) + .all() + ) + + return render_template( + "publish.html", + articles=articles, + week_start=week_start, + week_end=week_end, + week_str=f"{week_start.year}-W{week_start.isocalendar()[1]:02d}", + ) + + +@publish_bp.route("/publish", methods=["POST"]) +def create_issue(): + week_start_str = request.form.get("week_start") + week_end_str = request.form.get("week_end") + cover_method = request.form.get("cover_method", "text") + included_ids = request.form.getlist("article_ids", type=int) + + if not included_ids: + flash("No articles selected.", "error") + return redirect(url_for("publish.index")) + + week_start = date.fromisoformat(week_start_str) + week_end = date.fromisoformat(week_end_str) + + all_week_articles = ( + Article.query + .filter( + Article.pub_date >= str(week_start), + Article.pub_date < str(week_end + timedelta(days=1)), + ) + .all() + ) + all_ids = {a.id for a in all_week_articles} + excluded_ids = list(all_ids - set(included_ids)) + + headlines = [ + a.title for a in Article.query.filter(Article.id.in_(included_ids)) + .order_by(Article.pub_date.asc()).all() + ] + + try: + cover_path = generate_cover( + cover_method, config.ISSUES_DIR, week_start, week_end, headlines + ) + epub_path = build_epub( + week_start, week_end, included_ids, cover_path, config.ISSUES_DIR + ) + except Exception as e: + flash(f"Error generating issue: {e}", "error") + return redirect(url_for("publish.index")) + + issue = Issue( + week_start=week_start, + week_end=week_end, + cover_method=cover_method, + cover_path=cover_path, + epub_path=epub_path, + article_ids=json.dumps(included_ids), + excluded_article_ids=json.dumps(excluded_ids), + status="published", + ) + db.session.add(issue) + db.session.commit() + + flash(f"Issue published! {len(included_ids)} articles included.") + return redirect(url_for("issues.index")) diff --git a/src/routes/settings.py b/src/routes/settings.py new file mode 100644 index 0000000..2d3dca0 --- /dev/null +++ b/src/routes/settings.py @@ -0,0 +1,61 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app + +from src.models import Setting +import config + +settings_bp = Blueprint("settings", __name__) + + +@settings_bp.route("/settings", methods=["GET"]) +def index(): + feed_url = Setting.get("feed_url", default=config.FEED_URL) + fetch_interval = Setting.get("fetch_interval_hours", default=config.FETCH_INTERVAL_HOURS) + auto_publish = Setting.get("auto_publish", default=None) + max_landscape = Setting.get("image_max_landscape", default=list(config.IMAGE_MAX_LANDSCAPE)) + max_portrait = Setting.get("image_max_portrait", default=list(config.IMAGE_MAX_PORTRAIT)) + + return render_template( + "settings.html", + feed_url=feed_url, + fetch_interval=fetch_interval, + auto_publish=auto_publish, + max_landscape=max_landscape, + max_portrait=max_portrait, + ) + + +@settings_bp.route("/settings", methods=["POST"]) +def update(): + feed_url = request.form.get("feed_url", config.FEED_URL) + fetch_interval = int(request.form.get("fetch_interval", config.FETCH_INTERVAL_HOURS)) + + Setting.set("feed_url", feed_url) + config.FEED_URL = feed_url + + scheduler_mgr = current_app.config.get("SCHEDULER_MANAGER") + if scheduler_mgr: + scheduler_mgr.update_fetch_interval(fetch_interval) + + auto_enabled = request.form.get("auto_publish_enabled") == "on" + if auto_enabled: + day = request.form.get("auto_publish_day", "sun") + hour = int(request.form.get("auto_publish_hour", 6)) + minute = int(request.form.get("auto_publish_minute", 0)) + method = request.form.get("auto_publish_cover", "text") + if scheduler_mgr: + scheduler_mgr.enable_auto_publish(day, hour, minute, method) + else: + if scheduler_mgr: + scheduler_mgr.disable_auto_publish() + + lw = int(request.form.get("landscape_w", 800)) + lh = int(request.form.get("landscape_h", 480)) + pw = int(request.form.get("portrait_w", 480)) + ph = int(request.form.get("portrait_h", 800)) + Setting.set("image_max_landscape", [lw, lh]) + Setting.set("image_max_portrait", [pw, ph]) + config.IMAGE_MAX_LANDSCAPE = (lw, lh) + config.IMAGE_MAX_PORTRAIT = (pw, ph) + + flash("Settings saved.") + return redirect(url_for("settings.index")) diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..9cd999d --- /dev/null +++ b/static/style.css @@ -0,0 +1,38 @@ +:root { + --pico-font-size: 16px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + padding: 1rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: var(--pico-border-radius); + text-align: center; +} + +.stat-card .number { + font-size: 2rem; + font-weight: bold; + display: block; +} + +.stat-card .label { + font-size: 0.85rem; + color: var(--pico-muted-color); +} + +.action-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.hidden { display: none !important; } + +nav .brand { font-weight: bold; font-size: 1.1rem; } diff --git a/templates/articles.html b/templates/articles.html new file mode 100644 index 0000000..3af96c9 --- /dev/null +++ b/templates/articles.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Articles{% endblock %} +{% block content %} +

Articles

+ +
+ + + +
+ +

{{ articles|length }} articles found.

+ + + + + + + + + + + + {% for article in articles %} + + + + + + + {% endfor %} + +
DateTitleAuthorCategories
{{ article.pub_date.strftime('%b %d, %Y') }}{{ article.title }}{{ article.author }}{{ article.categories | replace('[', '') | replace(']', '') | replace('"', '') }}
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..adce26e --- /dev/null +++ b/templates/base.html @@ -0,0 +1,40 @@ + + + + + + {% block title %}PI Weekly{% endblock %} — Plymouth Independent + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+ PI Weekly Newspaper Generator +
+ {% block scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..4b9cb55 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} +

Dashboard

+ +
+
+ {{ articles_this_week }} + Articles This Week +
+
+ {{ total_articles }} + Total Cached +
+
+ {{ total_issues }} + Issues Published +
+
+ +
+

Scheduler

+

+ Status: {{ "Running" if scheduler_status.running else "Stopped" }} + {% if scheduler_status.rss_fetch %} + · Next fetch: {{ scheduler_status.rss_fetch.next_run }} + · Interval: {{ scheduler_status.rss_fetch.interval_hours }}h + {% endif %} +

+
+ +
+
+ +
+ New Issue +
+ +{% if latest_issue %} +

Latest Issue

+

+ {{ latest_issue.week_start }} – {{ latest_issue.week_end }} + · Download ePub +

+{% endif %} +{% endblock %} diff --git a/templates/issues.html b/templates/issues.html new file mode 100644 index 0000000..91b1580 --- /dev/null +++ b/templates/issues.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Issues{% endblock %} +{% block content %} +

Issues Archive

+ +{% if issues %} + + + + + + + + + + + + + {% for item in issues %} + + + + + + + + + {% endfor %} + +
CoverWeekArticlesCover MethodCreatedActions
+ Cover + {{ item.issue.week_start.strftime('%b %d') }} – {{ item.issue.week_end.strftime('%b %d, %Y') }}{{ item.article_count }}{{ item.issue.cover_method }}{{ item.issue.created_at.strftime('%b %d, %Y %H:%M') }} + + Download + +
+ +
+
+{% else %} +

No issues published yet. Create one?

+{% endif %} +{% endblock %} diff --git a/templates/publish.html b/templates/publish.html new file mode 100644 index 0000000..aa9acc6 --- /dev/null +++ b/templates/publish.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Publish{% endblock %} +{% block content %} +

Publish Issue

+ +
+ + +
+ +

{{ week_start.strftime('%b %d') }} – {{ week_end.strftime('%b %d, %Y') }} · {{ articles|length }} articles

+ +{% if articles %} +
+ + + + + + + + + + + + + + {% for article in articles %} + + + + + + + {% endfor %} + +
DateTitleAuthor
+ + {{ article.pub_date.strftime('%b %d') }}{{ article.title }}{{ article.author }}
+ +
+ Cover + + +
+ + +
+{% else %} +

No articles found for this week. Try fetching articles first from the Dashboard.

+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..ecb9b43 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Settings{% endblock %} +{% block content %} +

Settings

+ +
+ + + + +
+ Auto-Publish + +
+ + + + +
+
+ +
+ Image Constraints +
+ + + + +
+
+ + +
+{% endblock %}