feat: complete web UI — dashboard, articles, publish, settings, issues
Made-with: Cursor
This commit is contained in:
13
src/routes/__init__.py
Normal file
13
src/routes/__init__.py
Normal 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
48
src/routes/articles.py
Normal 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
47
src/routes/dashboard.py
Normal 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
77
src/routes/issues.py
Normal 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
102
src/routes/publish.py
Normal 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
61
src/routes/settings.py
Normal 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"))
|
||||
38
static/style.css
Normal file
38
static/style.css
Normal file
@@ -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; }
|
||||
48
templates/articles.html
Normal file
48
templates/articles.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Articles{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Articles</h1>
|
||||
|
||||
<form method="get" action="/articles" class="grid">
|
||||
<label>
|
||||
Week
|
||||
<input type="week" name="week" value="{{ week_filter }}">
|
||||
</label>
|
||||
<label>
|
||||
Category
|
||||
<select name="category">
|
||||
<option value="">All</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if cat == category_filter %}selected{% endif %}>{{ cat }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
<button type="submit">Filter</button>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<p>{{ articles|length }} articles found.</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
<th>Categories</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for article in articles %}
|
||||
<tr>
|
||||
<td>{{ article.pub_date.strftime('%b %d, %Y') }}</td>
|
||||
<td><a href="{{ article.link }}" target="_blank">{{ article.title }}</a></td>
|
||||
<td>{{ article.author }}</td>
|
||||
<td>{{ article.categories | replace('[', '') | replace(']', '') | replace('"', '') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
40
templates/base.html
Normal file
40
templates/base.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}PI Weekly{% endblock %} — Plymouth Independent</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li><a href="/" class="brand">PI Weekly</a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="/articles">Articles</a></li>
|
||||
<li><a href="/publish">Publish</a></li>
|
||||
<li><a href="/issues">Issues</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div role="alert" {% if category == 'error' %}class="pico-background-red-500"{% endif %}>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="container">
|
||||
<small>PI Weekly Newspaper Generator</small>
|
||||
</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
46
templates/dashboard.html
Normal file
46
templates/dashboard.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="number">{{ articles_this_week }}</span>
|
||||
<span class="label">Articles This Week</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="number">{{ total_articles }}</span>
|
||||
<span class="label">Total Cached</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="number">{{ total_issues }}</span>
|
||||
<span class="label">Issues Published</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hgroup>
|
||||
<h3>Scheduler</h3>
|
||||
<p>
|
||||
Status: <strong>{{ "Running" if scheduler_status.running else "Stopped" }}</strong>
|
||||
{% if scheduler_status.rss_fetch %}
|
||||
· Next fetch: {{ scheduler_status.rss_fetch.next_run }}
|
||||
· Interval: {{ scheduler_status.rss_fetch.interval_hours }}h
|
||||
{% endif %}
|
||||
</p>
|
||||
</hgroup>
|
||||
|
||||
<div class="action-buttons">
|
||||
<form method="post" action="/fetch-now">
|
||||
<button type="submit">Fetch Now</button>
|
||||
</form>
|
||||
<a href="/publish" role="button" class="outline">New Issue</a>
|
||||
</div>
|
||||
|
||||
{% if latest_issue %}
|
||||
<h3>Latest Issue</h3>
|
||||
<p>
|
||||
{{ latest_issue.week_start }} – {{ latest_issue.week_end }}
|
||||
· <a href="/issues/{{ latest_issue.id }}/download">Download ePub</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
44
templates/issues.html
Normal file
44
templates/issues.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Issues{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Issues Archive</h1>
|
||||
|
||||
{% if issues %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cover</th>
|
||||
<th>Week</th>
|
||||
<th>Articles</th>
|
||||
<th>Cover Method</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in issues %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="/issues/{{ item.issue.id }}/cover" alt="Cover"
|
||||
style="max-width: 100px; max-height: 60px;">
|
||||
</td>
|
||||
<td>{{ item.issue.week_start.strftime('%b %d') }} – {{ item.issue.week_end.strftime('%b %d, %Y') }}</td>
|
||||
<td>{{ item.article_count }}</td>
|
||||
<td>{{ item.issue.cover_method }}</td>
|
||||
<td>{{ item.issue.created_at.strftime('%b %d, %Y %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="/issues/{{ item.issue.id }}/download" role="button" class="outline secondary">
|
||||
Download
|
||||
</a>
|
||||
<form method="post" action="/issues/{{ item.issue.id }}/regenerate" style="display: inline;">
|
||||
<button type="submit" class="outline contrast">Regenerate</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No issues published yet. <a href="/publish">Create one?</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
75
templates/publish.html
Normal file
75
templates/publish.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Publish{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Publish Issue</h1>
|
||||
|
||||
<form method="get" action="/publish" style="margin-bottom: 1rem;">
|
||||
<label>
|
||||
Target Week
|
||||
<input type="week" name="week" value="{{ week_str }}">
|
||||
</label>
|
||||
<button type="submit" class="outline">Load Week</button>
|
||||
</form>
|
||||
|
||||
<p>{{ week_start.strftime('%b %d') }} – {{ week_end.strftime('%b %d, %Y') }} · {{ articles|length }} articles</p>
|
||||
|
||||
{% if articles %}
|
||||
<form method="post" action="/publish" id="publish-form">
|
||||
<input type="hidden" name="week_start" value="{{ week_start.isoformat() }}">
|
||||
<input type="hidden" name="week_end" value="{{ week_end.isoformat() }}">
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all" checked></th>
|
||||
<th>Date</th>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for article in articles %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" name="article_ids" value="{{ article.id }}" checked
|
||||
class="article-checkbox">
|
||||
</td>
|
||||
<td>{{ article.pub_date.strftime('%b %d') }}</td>
|
||||
<td>{{ article.title }}</td>
|
||||
<td>{{ article.author }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cover</legend>
|
||||
<label>
|
||||
<input type="radio" name="cover_method" value="ai" checked>
|
||||
AI Cover (Pollinations.ai)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="cover_method" value="text">
|
||||
Text Cover (fallback)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" id="publish-btn">Generate Issue</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>No articles found for this week. Try fetching articles first from the <a href="/">Dashboard</a>.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('select-all')?.addEventListener('change', function() {
|
||||
document.querySelectorAll('.article-checkbox').forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
|
||||
document.getElementById('publish-form')?.addEventListener('submit', function() {
|
||||
document.getElementById('publish-btn').setAttribute('aria-busy', 'true');
|
||||
document.getElementById('publish-btn').textContent = 'Generating...';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
79
templates/settings.html
Normal file
79
templates/settings.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Settings</h1>
|
||||
|
||||
<form method="post" action="/settings">
|
||||
<label>
|
||||
RSS Feed URL
|
||||
<input type="url" name="feed_url" value="{{ feed_url }}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fetch Interval (hours)
|
||||
<input type="number" name="fetch_interval" value="{{ fetch_interval }}" min="1" max="168" required>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Auto-Publish</legend>
|
||||
<label>
|
||||
<input type="checkbox" name="auto_publish_enabled" role="switch"
|
||||
{% if auto_publish %}checked{% endif %}>
|
||||
Enable auto-publish
|
||||
</label>
|
||||
<div class="grid">
|
||||
<label>
|
||||
Day
|
||||
<select name="auto_publish_day">
|
||||
{% for d in ['mon','tue','wed','thu','fri','sat','sun'] %}
|
||||
<option value="{{ d }}" {% if auto_publish and auto_publish.day_of_week == d %}selected{% endif %}>
|
||||
{{ d|capitalize }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Hour
|
||||
<input type="number" name="auto_publish_hour"
|
||||
value="{{ auto_publish.hour if auto_publish else 6 }}" min="0" max="23">
|
||||
</label>
|
||||
<label>
|
||||
Minute
|
||||
<input type="number" name="auto_publish_minute"
|
||||
value="{{ auto_publish.minute if auto_publish else 0 }}" min="0" max="59">
|
||||
</label>
|
||||
<label>
|
||||
Cover
|
||||
<select name="auto_publish_cover">
|
||||
<option value="ai" {% if auto_publish and auto_publish.cover_method == 'ai' %}selected{% endif %}>AI</option>
|
||||
<option value="text" {% if not auto_publish or auto_publish.cover_method == 'text' %}selected{% endif %}>Text</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Image Constraints</legend>
|
||||
<div class="grid">
|
||||
<label>
|
||||
Landscape Width
|
||||
<input type="number" name="landscape_w" value="{{ max_landscape[0] }}">
|
||||
</label>
|
||||
<label>
|
||||
Landscape Height
|
||||
<input type="number" name="landscape_h" value="{{ max_landscape[1] }}">
|
||||
</label>
|
||||
<label>
|
||||
Portrait Width
|
||||
<input type="number" name="portrait_w" value="{{ max_portrait[0] }}">
|
||||
</label>
|
||||
<label>
|
||||
Portrait Height
|
||||
<input type="number" name="portrait_h" value="{{ max_portrait[1] }}">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Save Settings</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user