feat: complete web UI — dashboard, articles, publish, settings, issues

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-06 15:21:18 -04:00
parent 50ff2e1533
commit ec9f31f072
13 changed files with 718 additions and 0 deletions

13
src/routes/__init__.py Normal file
View File

@@ -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)

48
src/routes/articles.py Normal file
View File

@@ -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 "",
)

47
src/routes/dashboard.py Normal file
View File

@@ -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"))

77
src/routes/issues.py Normal file
View File

@@ -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/<int:issue_id>/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/<int:issue_id>/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/<int:issue_id>/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"))

102
src/routes/publish.py Normal file
View File

@@ -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"))

61
src/routes/settings.py Normal file
View File

@@ -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"))