Files
kontra/app/src/templates.templ

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> &nbsp;·&nbsp; { strings.ToUpper(a.Subject) } &nbsp;·&nbsp; { 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>
&nbsp;{ 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/&lt;file&gt;</b>.
A &#123;&#123;media:...&#125;&#125; 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>
}