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 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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,24 +51,54 @@
|
|||||||
<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;">← Previous</button>
|
<button class="outline" id="prev-btn" onclick="rendition && rendition.prev()" style="padding:0.3rem 1rem;">← 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 →</button>
|
<button class="outline" id="next-btn" onclick="rendition && rendition.next()" style="padding:0.3rem 1rem;">Next →</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", {
|
|
||||||
|
fetch("/issues/{{ issue.id }}/epub")
|
||||||
|
.then(r => r.arrayBuffer())
|
||||||
|
.then(buf => {
|
||||||
|
book = ePub(buf);
|
||||||
|
rendition = book.renderTo("reader-viewport", {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
spread: "none",
|
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();
|
rendition.display();
|
||||||
|
|
||||||
book.loaded.navigation.then(function(nav) {
|
book.loaded.navigation.then(function(nav) {
|
||||||
const tocList = document.getElementById("toc-list");
|
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");
|
const item = document.createElement("a");
|
||||||
item.className = "toc-item";
|
item.className = "toc-item";
|
||||||
item.textContent = chapter.label.trim();
|
item.textContent = chapter.label.trim();
|
||||||
@@ -83,22 +114,40 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
rendition.on("relocated", function(location) {
|
rendition.on("relocated", function(location) {
|
||||||
const current = book.navigation && book.navigation.toc
|
const href = location.start.href;
|
||||||
? book.navigation.toc.findIndex(ch => location.start.href.includes(ch.href.split('#')[0]))
|
document.querySelectorAll(".toc-item").forEach(function(item, idx) {
|
||||||
: -1;
|
if (idx === 0) {
|
||||||
const total = book.navigation ? book.navigation.toc.length : 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) {
|
if (current >= 0) {
|
||||||
document.getElementById("chapter-info").textContent =
|
document.getElementById("chapter-info").textContent =
|
||||||
`Chapter ${current + 1} of ${total}`;
|
`${current + 1} of ${total}`;
|
||||||
document.querySelectorAll(".toc-item").forEach((item, idx) => {
|
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) {
|
document.addEventListener("keydown", function(e) {
|
||||||
if (e.key === "ArrowLeft") rendition.prev();
|
if (e.key === "ArrowLeft" && rendition) rendition.prev();
|
||||||
if (e.key === "ArrowRight") rendition.next();
|
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user