Web dashboard step 7: docs, users page, polish
Users page, README and config.example updates, implementation notes, the rollout runbook, site-layout 404/500 pages, human-readable download sizes, dark-mode and narrow-screen polish, and a smoke test over every dashboard route; also removes zdiff3 ancestor markers left by earlier merges. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -1185,4 +1185,45 @@ pub(crate) mod tests {
|
||||
assert!(body.contains("2 runs · max $0.11"), "{body}");
|
||||
assert!(!body.contains("style=\""), "no inline styles under the CSP");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_dashboard_route_template_renders_with_fixture_data() {
|
||||
let seed = seed().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO jobs
|
||||
(id, name, unit, requested_at, started_at, finished_at, status, message, run_id)
|
||||
VALUES (99, 'features-prune', 'daily-epub-job@features-prune.service',
|
||||
'2026-09-02T06:00:00Z', '2026-09-02T06:00:01Z',
|
||||
'2026-09-02T06:00:02Z', 'ok', 'pruned fixture rows', ?)",
|
||||
)
|
||||
.bind(seed.run_id)
|
||||
.execute(seed.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let app = app_with_users(&seed.db).await;
|
||||
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||
let routes = vec![
|
||||
"/dashboard".to_string(),
|
||||
"/dashboard/runs".to_string(),
|
||||
format!("/dashboard/runs/{}", seed.run_id),
|
||||
"/dashboard/articles".to_string(),
|
||||
"/dashboard/articles/1".to_string(),
|
||||
"/dashboard/ratings".to_string(),
|
||||
"/dashboard/ratings?tab=events".to_string(),
|
||||
"/dashboard/profile".to_string(),
|
||||
"/dashboard/stats?days=14".to_string(),
|
||||
"/dashboard/settings".to_string(),
|
||||
"/dashboard/settings/history".to_string(),
|
||||
"/dashboard/jobs".to_string(),
|
||||
"/dashboard/jobs/99".to_string(),
|
||||
"/dashboard/users".to_string(),
|
||||
];
|
||||
for uri in routes {
|
||||
let response = get(&app, &uri, Some(&admin)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK, "{uri}");
|
||||
let body = response_text(response).await;
|
||||
assert!(body.contains("<!doctype html>"), "{uri}: {body}");
|
||||
assert!(body.contains("The Daily EPUB"), "{uri}: {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
//! Dashboard: users pages. Filled in by web dashboard plan step 7.
|
||||
//! Dashboard: the read-only Users page (`/dashboard/users`, plan §6.1).
|
||||
|
||||
use askama::Template;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::routing::get;
|
||||
use axum_login::tower_sessions::Session;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::web::session::{AuthSession, Viewer};
|
||||
use crate::web::{Html, Page, WebError, format_time, take_flash};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UserLine {
|
||||
username: String,
|
||||
role: String,
|
||||
disabled: bool,
|
||||
created: String,
|
||||
last_login: String,
|
||||
open_sessions: i64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dashboard/users.html")]
|
||||
struct UsersTemplate {
|
||||
page: Page,
|
||||
users: Vec<UserLine>,
|
||||
}
|
||||
|
||||
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
Router::new().route("/dashboard/users", get(index))
|
||||
}
|
||||
|
||||
async fn index(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Html<UsersTemplate>, WebError> {
|
||||
let viewer = auth.user().await.map(Viewer::from);
|
||||
let config = state.config();
|
||||
let users = crate::web::users::list(&state.db)
|
||||
.await
|
||||
.map_err(WebError::Internal)?
|
||||
.into_iter()
|
||||
.map(|row| UserLine {
|
||||
username: row.user.username,
|
||||
role: row.user.role.to_string(),
|
||||
disabled: row.user.disabled,
|
||||
created: format_time(row.user.created_at, &config),
|
||||
last_login: row
|
||||
.user
|
||||
.last_login_at
|
||||
.map(|at| format_time(at, &config))
|
||||
.unwrap_or_else(|| "never".into()),
|
||||
open_sessions: row.open_sessions,
|
||||
})
|
||||
.collect();
|
||||
let mut page = Page::new("Users", viewer, "dashboard");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(UsersTemplate { page, users }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::web::dashboard::tests::{app_with_users, assert_admin_only, seed};
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
|
||||
let seed = seed().await;
|
||||
let app = app_with_users(&seed.db).await;
|
||||
let body = assert_admin_only(&app, "/dashboard/users").await;
|
||||
|
||||
assert!(body.contains("<h1>Users</h1>"), "{body}");
|
||||
assert!(body.contains("reader"), "{body}");
|
||||
assert!(body.contains("admin"), "{body}");
|
||||
assert!(body.contains("Open sessions"), "{body}");
|
||||
assert!(body.contains("daily-epub users"), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -25,6 +25,7 @@ pub struct Download {
|
||||
pub label: String,
|
||||
pub href: String,
|
||||
pub size_bytes: u64,
|
||||
pub size: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -192,9 +193,26 @@ fn download(
|
||||
label: label.to_string(),
|
||||
href: format!("/files/{kind}/{}", crate::web::encode_component(name)),
|
||||
size_bytes: metadata.len(),
|
||||
size: format_file_size(metadata.len()),
|
||||
})
|
||||
}
|
||||
|
||||
fn format_file_size(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
let bytes_float = bytes as f64;
|
||||
if bytes_float >= GB {
|
||||
format!("{:.1} GB", bytes_float / GB)
|
||||
} else if bytes_float >= MB {
|
||||
format!("{:.1} MB", bytes_float / MB)
|
||||
} else if bytes_float >= KB {
|
||||
format!("{:.1} KB", bytes_float / KB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FullEntry {
|
||||
title: String,
|
||||
@@ -1072,7 +1090,7 @@ mod tests {
|
||||
Edition::Standard,
|
||||
"epub",
|
||||
));
|
||||
std::fs::write(&standard, b"epub").unwrap();
|
||||
std::fs::write(&standard, 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));
|
||||
@@ -1089,10 +1107,19 @@ mod tests {
|
||||
.unwrap();
|
||||
let issue = response_text(issue).await;
|
||||
assert!(issue.contains("Download EPUB"));
|
||||
assert!(issue.contains("2.0 KB"));
|
||||
assert!(!issue.contains("2048 bytes"));
|
||||
assert!(!issue.contains("Download X4 EPUB"));
|
||||
assert!(!issue.contains("Download XTC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_sizes_are_human_readable() {
|
||||
assert_eq!(format_file_size(42), "42 B");
|
||||
assert_eq!(format_file_size(1536), "1.5 KB");
|
||||
assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rating_post_supports_json_forms_attribution_fallback_and_clear() {
|
||||
let (_dir, db, source) = seeded_issue(true).await;
|
||||
|
||||
+54
-14
@@ -244,7 +244,11 @@ impl<T: Template> IntoResponse for Html<T> {
|
||||
.into_response(),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "rendering web template failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
||||
error_page_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Server error",
|
||||
"The request could not be completed.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +280,22 @@ struct ErrorTemplate {
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn error_page_response(status: StatusCode, heading: &str, message: &str) -> Response {
|
||||
let rendered = ErrorTemplate {
|
||||
page: Page::new(heading, None, ""),
|
||||
heading: heading.to_string(),
|
||||
message: message.to_string(),
|
||||
}
|
||||
.render()
|
||||
.unwrap_or_else(|_| message.to_string());
|
||||
(
|
||||
status,
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
rendered,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
impl IntoResponse for WebError {
|
||||
fn into_response(self) -> Response {
|
||||
if let Self::Unauthenticated { next } = self {
|
||||
@@ -317,19 +337,7 @@ impl IntoResponse for WebError {
|
||||
}
|
||||
Self::Unauthenticated { .. } => unreachable!(),
|
||||
};
|
||||
let rendered = ErrorTemplate {
|
||||
page: Page::new(heading, None, ""),
|
||||
heading: heading.to_string(),
|
||||
message: message.to_string(),
|
||||
}
|
||||
.render()
|
||||
.unwrap_or_else(|_| message.to_string());
|
||||
(
|
||||
status,
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
rendered,
|
||||
)
|
||||
.into_response()
|
||||
error_page_response(status, heading, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +483,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
||||
.merge(account)
|
||||
.merge(full_issues)
|
||||
.merge(dashboard)
|
||||
.fallback(|| async { WebError::NotFound })
|
||||
}
|
||||
|
||||
async fn map_forbidden(request: Request, next: Next) -> Response {
|
||||
@@ -1000,4 +1009,35 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_and_server_error_pages_use_the_site_layout() {
|
||||
let (_dir, state) = test_state(Config::default()).await;
|
||||
let app = router(state);
|
||||
let missing = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/no-such-page")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
let missing = response_text(missing).await;
|
||||
assert!(missing.contains("<!doctype html>"), "{missing}");
|
||||
assert!(missing.contains("The Daily EPUB"), "{missing}");
|
||||
assert!(missing.contains("That page does not exist"), "{missing}");
|
||||
|
||||
let failed = WebError::Internal(anyhow::anyhow!("fixture failure")).into_response();
|
||||
assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let failed = response_text(failed).await;
|
||||
assert!(failed.contains("<!doctype html>"), "{failed}");
|
||||
assert!(failed.contains("The Daily EPUB"), "{failed}");
|
||||
assert!(
|
||||
failed.contains("The request could not be completed"),
|
||||
"{failed}"
|
||||
);
|
||||
assert!(!failed.contains("fixture failure"), "{failed}");
|
||||
}
|
||||
}
|
||||
|
||||
+21
-3
@@ -39,7 +39,7 @@ label { display:grid; gap:.25rem; }
|
||||
input,textarea,button { font:inherit; padding:.45rem; }
|
||||
.error { color:var(--down); }
|
||||
.dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; }
|
||||
.scroll-x { overflow-x:auto; }
|
||||
.scroll-x { max-width:100%; overflow-x:auto; overscroll-behavior-inline:contain; }
|
||||
table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; }
|
||||
th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; }
|
||||
thead { position:sticky; top:0; background:var(--bg); }
|
||||
@@ -106,7 +106,6 @@ details.explain { margin:1rem 0; }
|
||||
.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
|
||||
.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
|
||||
.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
|
||||
||||||| 849231e
|
||||
/* step 5: settings */
|
||||
.settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; }
|
||||
.settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; }
|
||||
@@ -124,7 +123,6 @@ details.explain { margin:1rem 0; }
|
||||
.settings .add-provider { max-width:36rem; }
|
||||
.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; }
|
||||
@media (max-width:40rem) { .setting { display:block; } }
|
||||
||||||| d36f203
|
||||
/* step 6: jobs and stats */
|
||||
.sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; }
|
||||
.spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; }
|
||||
@@ -137,3 +135,23 @@ details.explain { margin:1rem 0; }
|
||||
.job-cards form.inline { margin:.4rem 0 0; } .job-cards label { display:inline-grid; }
|
||||
td.message,dd.message { overflow-wrap:anywhere; max-width:32rem; }
|
||||
pre.journal { max-height:40rem; }
|
||||
/* step 7: dark-mode and narrow-screen polish */
|
||||
.primary,.admin { display:flex; flex-wrap:wrap; justify-content:center; gap:.35rem 1rem; }
|
||||
.masthead { line-height:1.15; overflow-wrap:anywhere; }
|
||||
.scroll-x > table { min-width:max-content; }
|
||||
input,textarea,select,button { max-width:100%; border-color:var(--rule); background:var(--bg); color:var(--fg); }
|
||||
button { border-style:solid; }
|
||||
input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
code,pre,.message { overflow-wrap:anywhere; }
|
||||
.prev-next a { overflow-wrap:anywhere; }
|
||||
@media (max-width:40rem) {
|
||||
body { padding-inline:.75rem; }
|
||||
.masthead { margin-top:.75rem; padding-inline:.25rem; }
|
||||
.primary,.admin { gap:.25rem .75rem; line-height:1.35; }
|
||||
.dashboard,.reading { margin-block:1.25rem; }
|
||||
.cards { grid-template-columns:minmax(0,1fr); }
|
||||
.card { padding:.65rem .75rem; }
|
||||
.prev-next { display:flex; flex-wrap:wrap; justify-content:space-between; }
|
||||
.rating { align-items:stretch; }
|
||||
.rating-prompt,.rating-note { flex-basis:100%; }
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ document.querySelectorAll("table[data-filter]").forEach((table) => {
|
||||
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
|
||||
});
|
||||
});
|
||||
||||||| 849231e
|
||||
});
|
||||
/* step 5: settings — "reset to default" fills the field with its default */
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-reset]");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% if signals.empty %}<p class="muted">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
|
||||
<table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
|
||||
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table>
|
||||
<div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
|
||||
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table></div>
|
||||
<p class="muted">Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
|
||||
{% if !signals.top_interests.is_empty() %}<p><strong>Top interests</strong></p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
|
||||
{% if !signals.neighbours.is_empty() %}<p><strong>Nearest rated neighbours</strong></p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<h1>Users</h1>
|
||||
<p class="muted">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
|
||||
<div class="scroll-x"><table data-filter>
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
||||
<tbody>{% for user in users %}<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td><span class="badge">{{ user.role }}</span></td>
|
||||
<td>{% if user.disabled %}<span class="badge down">disabled</span>{% else %}<span class="badge loved">enabled</span>{% endif %}</td>
|
||||
<td>{{ user.created }}</td>
|
||||
<td>{{ user.last_login }}</td>
|
||||
<td class="num">{{ user.open_sessions }}</td>
|
||||
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6">No users. Bootstrap an admin with <code>daily-epub users add <username> --admin</code>.</td></tr>{% endif %}</tbody>
|
||||
</table></div>
|
||||
</section>{% endblock %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<hr class="rule">
|
||||
<h1 class="kicker">The Brief</h1>
|
||||
<div class="editorial">{{ front_page_html|safe }}</div>
|
||||
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size_bytes }} bytes)</small></a>{% endfor %}</p>{% endif %}
|
||||
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size }})</small></a>{% endfor %}</p>{% endif %}
|
||||
<hr class="rule">
|
||||
<h1>In This Issue</h1>
|
||||
{% for section in sections %}<section><h2>{{ section.name }}</h2><ul class="index-list">{% for entry in section.entries %}<li class="index-entry">
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
|
||||
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
|
||||
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
|
||||
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size_bytes }} bytes)</a>{% endfor %}</p>{% endif %}
|
||||
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size }})</a>{% endfor %}</p>{% endif %}
|
||||
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
|
||||
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead"><a href="/">The Daily EPUB</a></header>
|
||||
<nav class="primary"><a href="/">Latest</a> · <a href="/issues">Archive</a> · <a href="/feed.xml">Feed</a> · {% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
|
||||
{% if page.is_admin() %}<nav class="admin"><a href="/dashboard">Overview</a> · <a href="/dashboard/runs">Runs</a> · <a href="/dashboard/articles">Articles</a> · <a href="/dashboard/ratings">Ratings</a> · <a href="/dashboard/profile">Profile</a> · <a href="/dashboard/stats">Stats</a> · <a href="/dashboard/jobs">Jobs</a> · <a href="/dashboard/settings">Settings</a> · <a href="/dashboard/users">Users</a></nav>{% endif %}
|
||||
<nav class="primary" aria-label="Site"><a href="/">Latest</a><a href="/issues">Archive</a><a href="/feed.xml">Feed</a>{% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
|
||||
{% if page.is_admin() %}<nav class="admin" aria-label="Dashboard"><a href="/dashboard">Overview</a><a href="/dashboard/runs">Runs</a><a href="/dashboard/articles">Articles</a><a href="/dashboard/ratings">Ratings</a><a href="/dashboard/profile">Profile</a><a href="/dashboard/stats">Stats</a><a href="/dashboard/jobs">Jobs</a><a href="/dashboard/settings">Settings</a><a href="/dashboard/users">Users</a></nav>{% endif %}
|
||||
{% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %}
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
<footer>daily-epub {{ page.version }}</footer>
|
||||
|
||||
Reference in New Issue
Block a user