Add admin dashboard links on article pages and a single download menu

Signed-in issue and article pages now carry an admin-only link to the
article's dashboard page. The separate Download EPUB / X4 EPUB / XTC
buttons become one "Download EPUB" button (Standard edition first) with a
no-JS <details> menu listing every available format and its size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
2026-09-06 17:18:23 +00:00
co-authored by Claude Fable 5.1
parent 142a8d9905
commit 71665c293c
5 changed files with 125 additions and 19 deletions
+5 -3
View File
@@ -199,9 +199,11 @@ The server is both the public newspaper index and the private operator UI. An
anonymous visitor sees titles, authors, sources, metadata, AI summaries, why
lines and outbound comment links; article bodies, the Brief, the World Briefing
and comments stay private. A signed-in `user`
sees complete issues and article chapters and can download artifacts. An
`admin` can additionally rate articles and use every `/dashboard/*` page,
including settings and jobs. Personalization is shared across accounts for now.
sees complete issues and article chapters and can download available formats
from a single download menu. An
`admin` can additionally follow direct dashboard links from issue entries and
article chapters, rate articles, and use every `/dashboard/*` page, including
settings and jobs. Personalization is shared across accounts for now.
Accounts are deliberately managed on the host, not in the browser. Usernames
are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
+116 -12
View File
@@ -31,12 +31,19 @@ pub struct Download {
pub size: String,
}
/// Available issue downloads, with the Standard EPUB first when it exists.
#[derive(Debug, Clone)]
pub struct Downloads {
primary: Download,
others: Vec<Download>,
}
#[derive(Debug, Clone)]
pub struct IssueView {
pub issue: Issue,
/// Signed-in BookOrbit redirect route when the Standard EPUB can be downloaded.
pub read_href: Option<String>,
pub downloads: Vec<Download>,
pub downloads: Option<Downloads>,
pub from_json: bool,
pub world_html: Option<String>,
pub is_latest: bool,
@@ -271,7 +278,7 @@ pub async fn load(
);
let read_href = (config.bookorbit.is_active() && standard_download.is_some())
.then(|| format!("/issues/{date}/read"));
let downloads = [
let mut available_downloads = [
standard_download,
download(
"X4 EPUB",
@@ -286,8 +293,11 @@ pub async fn load(
download("XTC", row.xtc_path.as_deref(), None, "xtc"),
]
.into_iter()
.flatten()
.collect();
.flatten();
let downloads = available_downloads.next().map(|primary| Downloads {
primary,
others: available_downloads.collect(),
});
Ok(Some(IssueView {
issue,
read_href,
@@ -842,6 +852,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
struct FullEntry {
title: String,
href: String,
dashboard_href: String,
source: String,
reading_minutes: i64,
is_lead: bool,
@@ -890,7 +901,7 @@ struct IssueFullTemplate {
stats_line: String,
front_page_html: String,
read_href: Option<String>,
downloads: Vec<Download>,
downloads: Option<Downloads>,
sections: Vec<FullSection>,
has_world: bool,
has_behind: bool,
@@ -920,6 +931,7 @@ struct ArticleTemplate {
body_html: String,
discussion_html: Option<String>,
read_online_url: String,
dashboard_href: String,
rating: Option<RatingWidget>,
previous: Option<ArticleLink>,
next: Option<ArticleLink>,
@@ -981,6 +993,7 @@ pub async fn render_full(
.map(|pick| FullEntry {
title: pick.article.title.clone(),
href: article_href(date, pick.article.id),
dashboard_href: format!("/dashboard/articles/{}", pick.article.id),
source: pick.article.feed_title.clone(),
reading_minutes: pick.article.reading_minutes(),
is_lead: pick.is_lead,
@@ -1097,6 +1110,7 @@ pub async fn article(
.as_ref()
.map(|discussion| crate::comments::render_xhtml(discussion, &article.title)),
read_online_url: article.url.clone(),
dashboard_href: format!("/dashboard/articles/{}", article.id),
rating: (viewer.role == crate::web::users::Role::Admin).then(|| {
RatingWidget::for_issue(
article.id,
@@ -1949,6 +1963,8 @@ mod tests {
assert!(!issue.contains("Was this a good pick?"));
let article_id = source.lineup.picks[0].article.id;
let dashboard_href = format!("/dashboard/articles/{article_id}");
assert!(!issue.contains(&dashboard_href));
let article = app
.clone()
.oneshot(
@@ -1971,6 +1987,7 @@ mod tests {
assert!(article.contains("referrerpolicy=\"no-referrer\""));
assert!(article.contains("A Niche Delight"));
assert!(article.contains("rel=\"next\""));
assert!(!article.contains(&dashboard_href));
let world = app
.clone()
@@ -2156,19 +2173,19 @@ mod tests {
}
#[tokio::test]
async fn downloads_are_listed_only_while_the_files_exist() {
async fn single_nonstandard_download_renders_as_the_primary_without_a_menu() {
let (dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let epub_dir = dir.path().join("epubs");
std::fs::create_dir(&epub_dir).unwrap();
let standard = epub_dir.join(crate::publish::issue_filename(
let x4 = epub_dir.join(crate::publish::issue_filename(
source.meta.date,
Edition::Standard,
Edition::X4,
"epub",
));
std::fs::write(&standard, vec![0; 2 * 1024]).unwrap();
std::fs::write(&x4, vec![0; 2 * 1024]).unwrap();
let mut config = crate::config::Config::default();
config.publish.epub_dir = epub_dir;
let app = crate::server::router(crate::server::AppState::new(db, config, None));
@@ -2184,11 +2201,79 @@ mod tests {
.await
.unwrap();
let issue = response_text(issue).await;
assert!(issue.contains("Download EPUB"));
assert!(issue.contains("2.0 KB"));
assert!(issue.contains(">Download X4 EPUB</a>"));
assert!(!issue.contains("2048 bytes"));
assert!(!issue.contains("Download X4 EPUB"));
assert!(!issue.contains(">Download EPUB</a>"));
assert!(!issue.contains("Download XTC"));
assert!(!issue.contains("Choose download format"));
}
#[tokio::test]
async fn download_menu_lists_all_formats_with_standard_first() {
let (dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let epub_dir = dir.path().join("epubs");
let xtc_dir = dir.path().join("xtc");
std::fs::create_dir(&epub_dir).unwrap();
std::fs::create_dir(&xtc_dir).unwrap();
let standard = epub_dir.join(crate::publish::issue_filename(
source.meta.date,
Edition::Standard,
"epub",
));
let x4 = epub_dir.join(crate::publish::issue_filename(
source.meta.date,
Edition::X4,
"epub",
));
let xtc = xtc_dir.join("issue.xtc");
std::fs::write(&standard, vec![0; 2 * 1024]).unwrap();
std::fs::write(&x4, vec![0; 3 * 1024]).unwrap();
std::fs::write(&xtc, vec![0; 4 * 1024]).unwrap();
sqlx::query("UPDATE issues SET xtc_path = ? WHERE date = ?")
.bind(xtc.to_string_lossy().as_ref())
.bind(source.meta.date.to_string())
.execute(db.pool())
.await
.unwrap();
let mut config = crate::config::Config::default();
config.publish.epub_dir = epub_dir;
let loaded = load(&db, &config, source.meta.date).await.unwrap().unwrap();
let downloads = loaded.downloads.as_ref().unwrap();
assert_eq!(downloads.primary.label, "EPUB");
assert_eq!(
downloads
.others
.iter()
.map(|download| download.label.as_str())
.collect::<Vec<_>>(),
["X4 EPUB", "XTC"]
);
let app = crate::server::router(crate::server::AppState::new(db, config, None));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let issue = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}", source.meta.date))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let issue = response_text(issue).await;
assert!(issue.contains(">Download EPUB</a>"));
assert!(issue.contains("Choose download format"));
assert!(issue.contains("Standard EPUB"));
assert!(issue.contains("X4 EPUB"));
assert!(issue.contains("XTC"));
assert!(issue.contains("2.0 KB"));
assert!(issue.contains("3.0 KB"));
assert!(issue.contains("4.0 KB"));
}
#[tokio::test]
@@ -2423,6 +2508,7 @@ mod tests {
let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await;
let reader_cookie = login_cookie(&app, "reader", "correct horse battery").await;
let article_id = source.lineup.picks[0].article.id;
let dashboard_href = format!("/dashboard/articles/{article_id}");
let json_response = app
.clone()
@@ -2474,6 +2560,24 @@ mod tests {
let admin_issue = response_text(admin_issue).await;
assert!(admin_issue.contains("Was this a good pick?"));
assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\""));
assert!(admin_issue.contains(&dashboard_href));
let admin_article = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/issues/{}/articles/{article_id}",
source.meta.date
))
.header(header::COOKIE, &admin_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(admin_article.status(), StatusCode::OK);
assert!(response_text(admin_article).await.contains(&dashboard_href));
let admin_behind = app
.clone()
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,6 +3,6 @@
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{{ meta_line }}</p>{% match why %}{% when Some with (why) %}<p class="mt-6 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6"><a class="btn" href="{{ read_online_url }}">Read online ↗</a></p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
</article>
</div>{% endblock %}
+2 -2
View File
@@ -3,9 +3,9 @@
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
<header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header>
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
{% if read_href.is_some() || !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
{% if read_href.is_some() || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</div>{% endif %}
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
</section>
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>