fix: delete issues, ePub reader (JSZip, linear cover, fonts), Pico dialog, UI polish

- Add POST /issues/<id>/delete route with file cleanup
- Fix ePub reader: add JSZip dependency, make cover linear in spine,
  inject system fonts into rendition
- Replace browser confirm() with Pico CSS dialog component
- Fix dashboard button sizing and consistency
- Add favicon, override Pico font stack to suppress Firefox warnings
- Compact issue action buttons

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-06 18:40:04 -04:00
parent c7c6cd979b
commit 872d90d9d9
7 changed files with 221 additions and 55 deletions

View File

@@ -1,7 +1,7 @@
import json import json
import os import os
import re import re
from datetime import date from datetime import date, datetime
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from ebooklib import epub from ebooklib import epub
@@ -67,6 +67,7 @@ def build_epub(
issue_type: str = "weekly", issue_type: str = "weekly",
) -> str: ) -> str:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d%H%M%S")
articles = ( articles = (
Article.query Article.query
@@ -91,7 +92,7 @@ def build_epub(
) )
book = epub.EpubBook() book = epub.EpubBook()
book.set_identifier(f"pi-{week_start.isoformat()}") book.set_identifier(f"pi-{week_start.isoformat()}-{issue_type}-{ts}")
book.set_title(title) book.set_title(title)
book.set_language("en") book.set_language("en")
book.add_author("Plymouth Independent") book.add_author("Plymouth Independent")
@@ -99,6 +100,11 @@ def build_epub(
with open(cover_path, "rb") as f: with open(cover_path, "rb") as f:
book.set_cover("cover.jpg", f.read()) book.set_cover("cover.jpg", f.read())
for item in book.get_items():
if item.get_name() == "cover.xhtml":
item.is_linear = True
break
style = epub.EpubItem( style = epub.EpubItem(
uid="style", file_name="style/default.css", uid="style", file_name="style/default.css",
media_type="text/css", content=EPUB_CSS.encode("utf-8"), media_type="text/css", content=EPUB_CSS.encode("utf-8"),
@@ -162,10 +168,16 @@ def build_epub(
book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav()) book.add_item(epub.EpubNav())
book.spine = ["nav"] + chapters book.spine = ["cover", "nav"] + chapters
iso_week = week_start.isocalendar()[1] iso_week = week_start.isocalendar()[1]
filename = f"plymouth-independent-{week_start.year}-W{iso_week:02d}.epub" if issue_type == "multi_week":
w2 = week_end.isocalendar()[1]
filename = f"plymouth-independent-{week_start.year}-W{iso_week:02d}-W{w2:02d}-{ts}.epub"
elif issue_type == "single_article":
filename = f"plymouth-independent-single-{ts}.epub"
else:
filename = f"plymouth-independent-{week_start.year}-W{iso_week:02d}-{ts}.epub"
epub_path = os.path.join(output_dir, filename) epub_path = os.path.join(output_dir, filename)
epub.write_epub(epub_path, book) epub.write_epub(epub_path, book)

View File

@@ -37,6 +37,15 @@ def download(issue_id):
) )
@issues_bp.route("/issues/<int:issue_id>/epub")
def epub_file(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, mimetype="application/epub+zip")
@issues_bp.route("/issues/<int:issue_id>/cover") @issues_bp.route("/issues/<int:issue_id>/cover")
def cover_image(issue_id): def cover_image(issue_id):
issue = Issue.query.get_or_404(issue_id) issue = Issue.query.get_or_404(issue_id)
@@ -73,6 +82,22 @@ def read(issue_id):
) )
@issues_bp.route("/issues/<int:issue_id>/delete", methods=["POST"])
def delete(issue_id):
issue = Issue.query.get_or_404(issue_id)
if issue.epub_path and os.path.exists(issue.epub_path):
os.remove(issue.epub_path)
if issue.cover_path and os.path.exists(issue.cover_path):
os.remove(issue.cover_path)
db.session.delete(issue)
db.session.commit()
flash("Issue deleted.")
return redirect(url_for("issues.index"))
@issues_bp.route("/issues/<int:issue_id>/regenerate", methods=["POST"]) @issues_bp.route("/issues/<int:issue_id>/regenerate", methods=["POST"])
def regenerate(issue_id): def regenerate(issue_id):
issue = Issue.query.get_or_404(issue_id) issue = Issue.query.get_or_404(issue_id)

View File

@@ -1,5 +1,7 @@
:root { :root {
--pico-font-size: 16px; --pico-font-size: 16px;
--pico-font-family-sans-serif: system-ui, -apple-system, "Segoe UI",
Helvetica, Arial, sans-serif, var(--pico-font-family-emoji);
} }
.stats-grid { .stats-grid {
@@ -31,6 +33,18 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
margin-bottom: 1.5rem;
}
.action-buttons form { margin: 0; }
.action-buttons a[role="button"],
.action-buttons button {
white-space: nowrap;
padding: 0.5rem 1rem;
font-size: 0.95rem;
line-height: 1.4;
margin: 0;
width: auto;
} }
.hidden { display: none !important; } .hidden { display: none !important; }
@@ -123,6 +137,29 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
border-bottom: 1px solid var(--pico-muted-border-color); border-bottom: 1px solid var(--pico-muted-border-color);
} }
/* Issue actions */
.issue-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.issue-actions form { margin: 0; }
.issue-actions a[role="button"],
.issue-actions button {
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
line-height: 1.4;
margin: 0;
width: auto;
}
.btn-danger { color: var(--pico-del-color); }
.btn-danger-fill {
background: var(--pico-del-color);
border-color: var(--pico-del-color);
color: #fff;
}
/* Consistent interactive elements */ /* Consistent interactive elements */
button, [role="button"], button, [role="button"],
input[type="submit"], input[type="submit"],

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}PI Weekly{% endblock %} — Plymouth Independent</title> <title>{% block title %}PI Weekly{% endblock %} — Plymouth Independent</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📰</text></svg>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> <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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
@@ -36,6 +37,48 @@
<footer class="container"> <footer class="container">
<small>PI Weekly Newspaper Generator</small> <small>PI Weekly Newspaper Generator</small>
</footer> </footer>
<dialog id="confirm-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closeConfirm()"></button>
<p><strong id="confirm-title">Confirm</strong></p>
</header>
<p id="confirm-message"></p>
<footer>
<button class="secondary" onclick="closeConfirm()">Cancel</button>
<button id="confirm-ok" onclick="submitConfirm()">Confirm</button>
</footer>
</article>
</dialog>
<script>
let _confirmTarget = null;
function confirmAction(message, formOrEl, title) {
_confirmTarget = formOrEl;
document.getElementById('confirm-title').textContent = title || 'Confirm';
document.getElementById('confirm-message').textContent = message;
const btn = document.getElementById('confirm-ok');
btn.className = '';
if (formOrEl.dataset.confirmDanger) {
btn.className = 'btn-danger-fill';
}
const dialog = document.getElementById('confirm-dialog');
document.documentElement.classList.add('modal-is-open', 'modal-is-opening');
dialog.showModal();
setTimeout(() => document.documentElement.classList.remove('modal-is-opening'), 200);
}
function closeConfirm() {
const dialog = document.getElementById('confirm-dialog');
document.documentElement.classList.add('modal-is-closing');
setTimeout(() => {
dialog.close();
document.documentElement.classList.remove('modal-is-open', 'modal-is-closing');
_confirmTarget = null;
}, 200);
}
function submitConfirm() {
if (_confirmTarget) _confirmTarget.submit();
}
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -34,7 +34,7 @@
<button type="submit">Fetch Now</button> <button type="submit">Fetch Now</button>
</form> </form>
<a href="/articles" role="button" class="outline">View</a> <a href="/articles" role="button" class="outline">View</a>
<a href="/publish" role="button">Publish New</a> <a href="/publish" role="button" class="outline">Publish New</a>
</div> </div>
{% if latest_issue %} {% if latest_issue %}

View File

@@ -26,16 +26,16 @@
<td>{{ item.article_count }}</td> <td>{{ item.article_count }}</td>
<td>{{ item.issue.cover_method }}</td> <td>{{ item.issue.cover_method }}</td>
<td>{{ item.issue.created_at.strftime('%b %d, %Y %H:%M') }}</td> <td>{{ item.issue.created_at.strftime('%b %d, %Y %H:%M') }}</td>
<td> <td class="issue-actions">
<a href="/issues/{{ item.issue.id }}/read" role="button" class="outline"> <a href="/issues/{{ item.issue.id }}/read" role="button" class="outline">Read</a>
Read <a href="/issues/{{ item.issue.id }}/download" role="button" class="outline">Download</a>
</a> <form method="post" action="/issues/{{ item.issue.id }}/regenerate">
<a href="/issues/{{ item.issue.id }}/download" role="button" class="outline">
Download
</a>
<form method="post" action="/issues/{{ item.issue.id }}/regenerate" style="display: inline;">
<button type="submit" class="outline contrast">Regenerate</button> <button type="submit" class="outline contrast">Regenerate</button>
</form> </form>
<form method="post" action="/issues/{{ item.issue.id }}/delete" data-confirm-danger="true"
onsubmit="event.preventDefault(); confirmAction('The ePub and cover files will also be removed.', this, 'Delete Issue?');">
<button type="submit" class="outline btn-danger">Delete</button>
</form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} — PI Weekly Reader</title> <title>{{ title }} — PI Weekly Reader</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📰</text></svg>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> <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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<style> <style>
@@ -50,56 +51,104 @@
<div id="reader-viewport"></div> <div id="reader-viewport"></div>
</div> </div>
<div class="reader-footer"> <div class="reader-footer">
<button class="outline" id="prev-btn" onclick="rendition.prev()" style="padding:0.3rem 1rem;">&larr; Previous</button> <button class="outline" id="prev-btn" onclick="rendition && rendition.prev()" style="padding:0.3rem 1rem;">&larr; Previous</button>
<small id="chapter-info"></small> <small id="chapter-info"></small>
<button class="outline" id="next-btn" onclick="rendition.next()" style="padding:0.3rem 1rem;">Next &rarr;</button> <button class="outline" id="next-btn" onclick="rendition && rendition.next()" style="padding:0.3rem 1rem;">Next &rarr;</button>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js"></script>
<script> <script>
const book = ePub("/issues/{{ issue.id }}/download"); let book, rendition;
const rendition = book.renderTo("reader-viewport", {
width: "100%",
height: "100%",
spread: "none",
});
rendition.display();
book.loaded.navigation.then(function(nav) { fetch("/issues/{{ issue.id }}/epub")
const tocList = document.getElementById("toc-list"); .then(r => r.arrayBuffer())
nav.toc.forEach(function(chapter, idx) { .then(buf => {
const item = document.createElement("a"); book = ePub(buf);
item.className = "toc-item"; rendition = book.renderTo("reader-viewport", {
item.textContent = chapter.label.trim(); width: "100%",
item.href = "#"; height: "100%",
item.onclick = function(e) { spread: "none",
e.preventDefault();
rendition.display(chapter.href);
document.querySelectorAll(".toc-item").forEach(i => i.classList.remove("active"));
item.classList.add("active");
};
tocList.appendChild(item);
});
});
rendition.on("relocated", function(location) {
const current = book.navigation && book.navigation.toc
? book.navigation.toc.findIndex(ch => location.start.href.includes(ch.href.split('#')[0]))
: -1;
const total = book.navigation ? book.navigation.toc.length : 0;
if (current >= 0) {
document.getElementById("chapter-info").textContent =
`Chapter ${current + 1} of ${total}`;
document.querySelectorAll(".toc-item").forEach((item, idx) => {
item.classList.toggle("active", idx === current);
}); });
}
});
document.addEventListener("keydown", function(e) { rendition.themes.default({
if (e.key === "ArrowLeft") rendition.prev(); body: {
if (e.key === "ArrowRight") rendition.next(); "font-family": 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
}); "line-height": "1.6",
},
"h1, h2, h3": {
"font-family": 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
},
});
rendition.display();
book.loaded.navigation.then(function(nav) {
const tocList = document.getElementById("toc-list");
const coverItem = document.createElement("a");
coverItem.className = "toc-item";
coverItem.textContent = "Cover";
coverItem.href = "#";
coverItem.onclick = function(e) {
e.preventDefault();
rendition.display("cover.xhtml");
document.querySelectorAll(".toc-item").forEach(i => i.classList.remove("active"));
coverItem.classList.add("active");
};
tocList.appendChild(coverItem);
nav.toc.forEach(function(chapter) {
const item = document.createElement("a");
item.className = "toc-item";
item.textContent = chapter.label.trim();
item.href = "#";
item.onclick = function(e) {
e.preventDefault();
rendition.display(chapter.href);
document.querySelectorAll(".toc-item").forEach(i => i.classList.remove("active"));
item.classList.add("active");
};
tocList.appendChild(item);
});
});
rendition.on("relocated", function(location) {
const href = location.start.href;
document.querySelectorAll(".toc-item").forEach(function(item, idx) {
if (idx === 0) {
item.classList.toggle("active", href.includes("cover.xhtml"));
}
});
if (book.navigation && book.navigation.toc) {
const current = book.navigation.toc.findIndex(
ch => href.includes(ch.href.split('#')[0])
);
const total = book.navigation.toc.length;
if (current >= 0) {
document.getElementById("chapter-info").textContent =
`${current + 1} of ${total}`;
document.querySelectorAll(".toc-item").forEach((item, idx) => {
if (idx > 0) item.classList.toggle("active", idx - 1 === current);
else item.classList.remove("active");
});
} else if (href.includes("cover.xhtml")) {
document.getElementById("chapter-info").textContent = "Cover";
} else if (href.includes("nav.xhtml")) {
document.getElementById("chapter-info").textContent = "Table of Contents";
}
}
});
document.addEventListener("keydown", function(e) {
if (e.key === "ArrowLeft" && rendition) rendition.prev();
if (e.key === "ArrowRight" && rendition) rendition.next();
});
})
.catch(err => {
document.getElementById("reader-viewport").innerHTML =
`<p style="padding:2rem;color:red;">Failed to load ePub: ${err.message}</p>`;
});
function toggleToc() { function toggleToc() {
document.getElementById("toc-sidebar").classList.toggle("collapsed"); document.getElementById("toc-sidebar").classList.toggle("collapsed");