338 lines
11 KiB
Plaintext
338 lines
11 KiB
Plaintext
package main
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Page is the shell info for every page.
|
|
type Page struct {
|
|
Title string
|
|
Slug string
|
|
ActiveSubject string
|
|
}
|
|
|
|
// ViewData carries everything a page render needs.
|
|
type ViewData struct {
|
|
Site SiteConfig
|
|
Subjects []*Subject
|
|
Featured *Article
|
|
Rail []*Article
|
|
Latest []*Article
|
|
Subject *Subject // set on subject pages
|
|
Article *Article // set on article pages
|
|
Related []*Article
|
|
}
|
|
|
|
templ PageView(page Page, data *ViewData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ page.Title }</title>
|
|
<link rel="stylesheet" href="/web/assets/kontra.css"/>
|
|
</head>
|
|
<body>
|
|
@Masthead(page, data)
|
|
<main class="container">
|
|
if data.Subject != nil {
|
|
@SubjectIndexContent(data.Subject)
|
|
} else if data.Article != nil {
|
|
@ArticleContent(data.Article, data)
|
|
} else {
|
|
@HomeContent(data)
|
|
}
|
|
</main>
|
|
@MobileNav(page, data)
|
|
@SiteFooter(data)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// Masthead: meta line, centered brand, ribbon with dropdowns.
|
|
templ Masthead(page Page, data *ViewData) {
|
|
<header class="masthead">
|
|
<div class="container">
|
|
<div class="meta-line">
|
|
<span>Wednesday, 24 Sep • Vol. I</span>
|
|
<span class="edition">Morning Edition</span>
|
|
</div>
|
|
<div class="brand-row">
|
|
<div aria-hidden="true"></div>
|
|
<div class="brand">
|
|
<a href="/" class="display-masthead">{ data.Site.Name }</a>
|
|
</div>
|
|
<div class="brand-tools">
|
|
<a href="/search" aria-label="Search"><span class="material-symbols-outlined">search</span></a>
|
|
<a href="/account" class="brand-avatar" aria-label="Account">S</a>
|
|
</div>
|
|
</div>
|
|
<nav aria-label="Sections" class="ribbon">
|
|
<div class={ "nav-item", templ.KV(" active", page.Slug == "front-page") }>
|
|
<a href="/">Front Page</a>
|
|
</div>
|
|
for _, s := range data.Subjects {
|
|
<div class={ "nav-item", templ.KV(" active", s.Slug == page.ActiveSubject) }>
|
|
<a href={ "/subjects/" + s.Slug }>{ s.Name }</a>
|
|
<div class="nav-drop">
|
|
for _, a := range firstArticles(s.Articles, 2) {
|
|
<a class="drop-item" href={ "/articles/" + a.Slug }>
|
|
<span class="drop-kicker">{ a.FM.Kicker }</span>
|
|
<span class="drop-title">{ a.FM.Title }</span>
|
|
</a>
|
|
}
|
|
<a class="drop-all" href={ "/subjects/" + s.Slug }>All { s.Name } →</a>
|
|
</div>
|
|
</div>
|
|
}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
}
|
|
|
|
templ MobileNav(page Page, data *ViewData) {
|
|
<nav class="bottom-nav" aria-label="Mobile navigation">
|
|
<div class="bottom-nav-inner">
|
|
<a class={ "bn-item", templ.KV(" active", page.Slug == "front-page") } href="/">
|
|
<span class="material-symbols-outlined ic">newspaper</span><span>Edition</span>
|
|
</a>
|
|
<details class="bn-sections">
|
|
<summary><span class="material-symbols-outlined ic">segment</span><span>Sections</span></summary>
|
|
<div class="sections-drawer">
|
|
<a href="/">Front Page</a>
|
|
for _, s := range data.Subjects {
|
|
<a href={ "/subjects/" + s.Slug }>{ s.Name }</a>
|
|
}
|
|
<a href="/subjects" class="dr-all">All Sections</a>
|
|
</div>
|
|
</details>
|
|
<a class="bn-item" href="/reader"><span class="material-symbols-outlined ic">format_size</span><span>Reader</span></a>
|
|
<a class="bn-item" href="/saved"><span class="material-symbols-outlined ic">bookmark_border</span><span>Saved</span></a>
|
|
</div>
|
|
</nav>
|
|
}
|
|
|
|
templ SiteFooter(data *ViewData) {
|
|
<footer class="site-footer">
|
|
<div class="container">
|
|
<div class="t">{ data.Site.Name }</div>
|
|
<div class="tag">{ data.Site.Tagline }</div>
|
|
<div class="copy">© 2026 Kontra</div>
|
|
</div>
|
|
</footer>
|
|
}
|
|
|
|
templ FeedRow(a *Article) {
|
|
<a class="feed-row" href={ "/articles/" + a.Slug }>
|
|
if a.FM.Kicker != "" {
|
|
<span class="f-kicker">{ a.FM.Kicker }</span>
|
|
} else {
|
|
<span class="f-kicker">Kontra</span>
|
|
}
|
|
<span class="f-head">{ a.FM.Title }</span>
|
|
<span class="f-sub">{ a.FM.Excerpt }</span>
|
|
<span class="f-meta">By { a.FM.Author } • { a.FM.Date }</span>
|
|
</a>
|
|
}
|
|
|
|
templ SubjectBox(s *Subject) {
|
|
<div class="subject-box">
|
|
<span class="kicker">{ s.Name }</span>
|
|
if len(s.Articles) > 0 {
|
|
<a class="s-head" href={ "/articles/" + s.Articles[0].Slug }>{ s.Articles[0].FM.Title }</a>
|
|
<a class="s-sub" href={ "/articles/" + s.Articles[0].Slug }>{ s.Articles[0].FM.Excerpt }</a>
|
|
<span class="s-by">By { s.Articles[0].FM.Author }</span>
|
|
}
|
|
<a class="s-more" href={ "/subjects/" + s.Slug }>More { s.Name }</a>
|
|
</div>
|
|
}
|
|
|
|
templ HomeContent(data *ViewData) {
|
|
<div class="lead-grid">
|
|
<article>
|
|
@LeadStory(data.Featured)
|
|
</article>
|
|
<aside class="hairline-t" style="padding:8px 0;">
|
|
<div class="label-caps" style="color:var(--ink-muted);">Top Stories</div>
|
|
@RailStories(data.Rail)
|
|
</aside>
|
|
</div>
|
|
|
|
<section class="hairline-t" style="padding:32px 0;">
|
|
<h2 class="label-caps" style="color:var(--ink);margin:0 0 20px;">Topics</h2>
|
|
<div class="subject-grid">
|
|
for _, s := range firstSubjects(data.Subjects, 4) {
|
|
@SubjectBox(s)
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="hairline-t" style="padding:32px 0;">
|
|
<h2 class="label-caps" style="color:var(--ink);margin:0 0 20px;">Latest</h2>
|
|
<div class="latest-grid">
|
|
for _, a := range data.Latest {
|
|
@FeedRow(a)
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
templ LeadStory(a *Article) {
|
|
if a != nil {
|
|
if a.FM.Kicker != "" {
|
|
<div class="label-caps" style="color:var(--accent);margin:0 0 6px;">{ a.FM.Kicker }</div>
|
|
} else {
|
|
<div class="label-caps" style="color:var(--accent);margin:0 0 6px;">World Dispatch</div>
|
|
}
|
|
<h1 class="headline-xl" style="color:var(--ink);margin:0 0 8px;">
|
|
<a href={ "/articles/" + a.Slug } style="text-decoration:none;color:var(--ink);">{ a.FM.Title }</a>
|
|
</h1>
|
|
<p class="body-sm" style="color:var(--ink-muted);margin:0;">{ a.FM.Excerpt }</p>
|
|
if a.FM.Image != "" {
|
|
<figure class="plate" style="margin-top:16px;">
|
|
<img src={ a.FM.Image } alt={ a.FM.Title }/>
|
|
<figcaption class="body-sm" style="padding:12px 16px;border:1px solid var(--hairline);color:var(--ink-muted);">
|
|
<span class="label-caps" style="color:var(--ink);">Fig. 1</span>
|
|
<span style="font-size:12px;">Plate No. 12-A • { a.FM.Author } / Kontra</span>
|
|
</figcaption>
|
|
</figure>
|
|
}
|
|
<div class="label-meta" style="color:var(--ink-faint);margin-top:12px;">By { a.FM.Author } • { a.FM.Date }</div>
|
|
}
|
|
}
|
|
|
|
templ RailStories(rail []*Article) {
|
|
for _, a := range rail {
|
|
<a href={ "/articles/" + a.Slug } class="feed-row" style="margin-top:8px;">
|
|
if a.FM.Kicker != "" {
|
|
<span class="f-kicker">{ a.FM.Kicker }</span>
|
|
} else {
|
|
<span class="f-kicker">Kontra</span>
|
|
}
|
|
<span class="f-head">{ a.FM.Title }</span>
|
|
<span class="f-meta">By { a.FM.Author }</span>
|
|
</a>
|
|
}
|
|
}
|
|
|
|
templ SubjectIndexContent(s *Subject) {
|
|
<div class="subject-mast">
|
|
<div class="label-caps" style="color:var(--accent);">Topic</div>
|
|
<h1 class="headline-xl" style="color:var(--ink);margin:8px 0 4px;">{ s.Name }</h1>
|
|
<div class="body-sm" style="color:var(--ink-muted);">{ strconv.FormatInt(int64(len(s.Articles)), 10) } stories</div>
|
|
</div>
|
|
<section style="padding:24px 0;border-top:1px solid var(--hairline);">
|
|
<div class="latest-grid">
|
|
for _, a := range s.Articles {
|
|
@FeedRow(a)
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
templ ArticleContent(a *Article, data *ViewData) {
|
|
<article class="content-column">
|
|
<header class="article-header">
|
|
if a.FM.Kicker != "" {
|
|
<div class="kicker">{ a.FM.Kicker }</div>
|
|
} else {
|
|
<div class="kicker">Kontra</div>
|
|
}
|
|
<h1>{ a.FM.Title }</h1>
|
|
<div class="deck">{ a.FM.Excerpt }</div>
|
|
<div class="meta">
|
|
<strong>By { a.FM.Author }</strong> · { strings.ToUpper(a.Subject) } · { a.FM.Date }
|
|
</div>
|
|
</header>
|
|
|
|
if a.FM.Image != "" {
|
|
<figure class="plate" style="margin:24px 0 8px;">
|
|
<img src={ a.FM.Image } alt={ a.FM.Title }/>
|
|
<figcaption class="body-sm" style="padding:12px 16px;color:var(--ink-muted);">
|
|
<span class="label-caps" style="color:var(--ink);">Fig. 1</span>
|
|
{ a.FM.Excerpt }
|
|
</figcaption>
|
|
</figure>
|
|
}
|
|
|
|
<div class="article-body">
|
|
@templ.Raw(a.BodyHTML)
|
|
</div>
|
|
|
|
<div class="save-module">
|
|
<div class="l">
|
|
<div class="t">Dispatch Status</div>
|
|
<div class="s">Filed • { a.FM.Date }</div>
|
|
</div>
|
|
<button class="btn-primary" type="button">Save Story</button>
|
|
</div>
|
|
</article>
|
|
|
|
<section class="related">
|
|
<div class="container">
|
|
<div class="r-head">
|
|
<span class="l">Related Dispatches</span>
|
|
<span class="r">From the Field Archives</span>
|
|
</div>
|
|
<div style="margin-top:16px;">
|
|
for _, r := range data.Related {
|
|
@FeedRow(r)
|
|
}
|
|
</div>
|
|
@NewsletterCard()
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
templ NewsletterCard() {
|
|
<div class="newsletter-card">
|
|
<span class="t">The Kontra Registry</span>
|
|
<p>Sunday editions, curated by our roaming field correspondents. Delivered straight to your reader feed.</p>
|
|
<form>
|
|
<input class="field" type="email" placeholder="reader@kontra.day" required/>
|
|
<button class="btn-primary" type="submit">Subscribe</button>
|
|
</form>
|
|
</div>
|
|
}
|
|
|
|
templ AdminRedirect() {
|
|
<!DOCTYPE html>
|
|
<html><head><meta http-equiv="refresh" content="0; url=/admin/"/></head><body></body></html>
|
|
}
|
|
|
|
// AdminMediaPage is a browser-based media uploader that posts to
|
|
// /admin/media/upload (which stores into Garage S3 via minio-go) and
|
|
// returns the {{media:...}} snippet to paste into an article.
|
|
templ AdminMediaPage() {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Kontra — Media Library</title>
|
|
<link rel="stylesheet" href="/web/assets/kontra.css"/>
|
|
</head>
|
|
<body class="admin-body">
|
|
<header class="masthead"><div class="container">
|
|
<div class="brand-row"><div aria-hidden="true"></div>
|
|
<div class="brand"><a href="/" class="display-masthead">Kontra</a></div>
|
|
<div class="brand-tools"><a href="/admin" class="nav-link">CMS</a></div>
|
|
</div>
|
|
</div></header>
|
|
<main class="container">
|
|
<section style="padding:24px 0;">
|
|
<h1 class="headline-lg" style="color:var(--ink);">Media Library</h1>
|
|
<div class="body-sm" style="color:var(--ink-muted);margin:8px 0 16px;">
|
|
Upload an image — it is stored in Garage S3 and served at <b>/media/<file></b>.
|
|
A {{media:...}} snippet is shown to paste into an article.
|
|
</div>
|
|
<form method="post" action="/admin/media/upload" enctype="multipart/form-data" class="newsletter-card">
|
|
<input class="field" type="file" name="file" required/>
|
|
<button class="btn-primary" type="submit">Upload</button>
|
|
</form>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
} |