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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,24 +51,54 @@
|
||||
<div id="reader-viewport"></div>
|
||||
</div>
|
||||
<div class="reader-footer">
|
||||
<button class="outline" id="prev-btn" onclick="rendition.prev()" style="padding:0.3rem 1rem;">← Previous</button>
|
||||
<button class="outline" id="prev-btn" onclick="rendition && rendition.prev()" style="padding:0.3rem 1rem;">← Previous</button>
|
||||
<small id="chapter-info"></small>
|
||||
<button class="outline" id="next-btn" onclick="rendition.next()" style="padding:0.3rem 1rem;">Next →</button>
|
||||
<button class="outline" id="next-btn" onclick="rendition && rendition.next()" style="padding:0.3rem 1rem;">Next →</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", {
|
||||
let book, rendition;
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
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");
|
||||
nav.toc.forEach(function(chapter, idx) {
|
||||
|
||||
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();
|
||||
@@ -83,22 +114,40 @@
|
||||
});
|
||||
|
||||
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;
|
||||
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 =
|
||||
`Chapter ${current + 1} of ${total}`;
|
||||
`${current + 1} of ${total}`;
|
||||
document.querySelectorAll(".toc-item").forEach((item, idx) => {
|
||||
item.classList.toggle("active", idx === current);
|
||||
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.prev();
|
||||
if (e.key === "ArrowRight") rendition.next();
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user