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 os
import re
from datetime import date
from datetime import date, datetime
from bs4 import BeautifulSoup
from ebooklib import epub
@@ -67,6 +67,7 @@ def build_epub(
issue_type: str = "weekly",
) -> str:
os.makedirs(output_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d%H%M%S")
articles = (
Article.query
@@ -91,7 +92,7 @@ def build_epub(
)
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_language("en")
book.add_author("Plymouth Independent")
@@ -99,6 +100,11 @@ def build_epub(
with open(cover_path, "rb") as f:
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(
uid="style", file_name="style/default.css",
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.EpubNav())
book.spine = ["nav"] + chapters
book.spine = ["cover", "nav"] + chapters
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.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")
def cover_image(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"])
def regenerate(issue_id):
issue = Issue.query.get_or_404(issue_id)

View File

@@ -1,5 +1,7 @@
:root {
--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 {
@@ -31,6 +33,18 @@
display: flex;
gap: 0.5rem;
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; }
@@ -123,6 +137,29 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
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 */
button, [role="button"],
input[type="submit"],

View File

@@ -4,6 +4,7 @@
<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="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="{{ url_for('static', filename='style.css') }}">
</head>
@@ -36,6 +37,48 @@
<footer class="container">
<small>PI Weekly Newspaper Generator</small>
</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 %}
</body>
</html>

View File

@@ -34,7 +34,7 @@
<button type="submit">Fetch Now</button>
</form>
<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>
{% if latest_issue %}

View File

@@ -26,16 +26,16 @@
<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 }}/read" role="button" class="outline">
Read
</a>
<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;">
<td class="issue-actions">
<a href="/issues/{{ item.issue.id }}/read" role="button" class="outline">Read</a>
<a href="/issues/{{ item.issue.id }}/download" role="button" class="outline">Download</a>
<form method="post" action="/issues/{{ item.issue.id }}/regenerate">
<button type="submit" class="outline contrast">Regenerate</button>
</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>
</tr>
{% endfor %}

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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="{{ url_for('static', filename='style.css') }}">
<style>
@@ -50,56 +51,104 @@
<div id="reader-viewport"></div>
</div>
<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>
<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>
<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>
const book = ePub("/issues/{{ issue.id }}/download");
const rendition = book.renderTo("reader-viewport", {
width: "100%",
height: "100%",
spread: "none",
});
rendition.display();
let book, rendition;
book.loaded.navigation.then(function(nav) {
const tocList = document.getElementById("toc-list");
nav.toc.forEach(function(chapter, idx) {
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 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);
fetch("/issues/{{ issue.id }}/epub")
.then(r => r.arrayBuffer())
.then(buf => {
book = ePub(buf);
rendition = book.renderTo("reader-viewport", {
width: "100%",
height: "100%",
spread: "none",
});
}
});
document.addEventListener("keydown", function(e) {
if (e.key === "ArrowLeft") rendition.prev();
if (e.key === "ArrowRight") rendition.next();
});
rendition.themes.default({
body: {
"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() {
document.getElementById("toc-sidebar").classList.toggle("collapsed");