Bootstrapped during the 2026-06-06 BlockNinja consolidation. Was previously an unversioned directory inside ~/src/blockninja-themes/noir. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
289 lines
8.9 KiB
Plaintext
289 lines
8.9 KiB
Plaintext
package main
|
|
|
|
import (
|
|
"context"
|
|
|
|
"git.dev.alexdunmow.com/block/core/templates/bn"
|
|
)
|
|
|
|
// NoirPageData holds the parsed view-model for every Noir page template.
|
|
type NoirPageData struct {
|
|
Title string
|
|
Slots map[string]string
|
|
ThemeMode string
|
|
ThemeCSS string
|
|
SiteSettings bn.SiteSettingsData
|
|
PageMeta bn.PageMeta
|
|
StructuredData string
|
|
CSSHash string
|
|
PageviewNonce string
|
|
EngagementConfig bn.EngagementConfig
|
|
}
|
|
|
|
func parseNoirPageData(doc map[string]any) NoirPageData {
|
|
title := "Untitled"
|
|
if t, ok := doc["title"].(string); ok {
|
|
title = t
|
|
}
|
|
|
|
slots := make(map[string]string)
|
|
if s, ok := doc["slots"].(map[string]string); ok {
|
|
slots = s
|
|
}
|
|
|
|
themeCSS := ""
|
|
if tc, ok := doc["theme_css"].(string); ok {
|
|
themeCSS = tc
|
|
}
|
|
|
|
structuredData := ""
|
|
if sd, ok := doc["structured_data"].(string); ok {
|
|
structuredData = sd
|
|
}
|
|
|
|
cssHash := ""
|
|
if ch, ok := doc["css_hash"].(string); ok {
|
|
cssHash = ch
|
|
}
|
|
|
|
pageviewNonce := ""
|
|
if pn, ok := doc["pageview_nonce"].(string); ok {
|
|
pageviewNonce = pn
|
|
}
|
|
|
|
themeMode := "dark"
|
|
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
|
|
themeMode = tm
|
|
}
|
|
|
|
siteSettings := bn.ParseSiteSettings(doc)
|
|
pageMeta := bn.ParsePageMeta(doc)
|
|
engagementConfig := bn.ParseEngagementConfig(doc)
|
|
|
|
return NoirPageData{
|
|
Title: title,
|
|
Slots: slots,
|
|
ThemeMode: themeMode,
|
|
ThemeCSS: themeCSS,
|
|
SiteSettings: siteSettings,
|
|
PageMeta: pageMeta,
|
|
StructuredData: structuredData,
|
|
CSSHash: cssHash,
|
|
PageviewNonce: pageviewNonce,
|
|
EngagementConfig: engagementConfig,
|
|
}
|
|
}
|
|
|
|
// noirHead emits the shared <head> block plus the lightbox-bootstrap script.
|
|
templ noirHead(data NoirPageData) {
|
|
@bn.Head(bn.HeadData{
|
|
Title: data.Title,
|
|
Settings: data.SiteSettings,
|
|
PageMeta: data.PageMeta,
|
|
ThemeMode: data.ThemeMode,
|
|
ThemeCSS: data.ThemeCSS,
|
|
PluginStyles: []string{"/templates/noir/style.css"},
|
|
StructuredData: data.StructuredData,
|
|
CSSHash: data.CSSHash,
|
|
PageviewNonce: data.PageviewNonce,
|
|
EngagementConfig: data.EngagementConfig,
|
|
})
|
|
}
|
|
|
|
// noirLightboxScript injects the vanilla keyboard-aware lightbox handler.
|
|
// Stays tiny (no deps) and uses the data attributes set by lightbox_gallery.
|
|
templ noirLightboxScript() {
|
|
<script>
|
|
(function() {
|
|
if (window.__noirLightboxInit) return;
|
|
window.__noirLightboxInit = true;
|
|
var overlay = null;
|
|
function ensureOverlay() {
|
|
if (overlay) return overlay;
|
|
overlay = document.createElement('div');
|
|
overlay.setAttribute('data-noir-lightbox', '');
|
|
overlay.setAttribute('aria-hidden', 'true');
|
|
overlay.setAttribute('role', 'dialog');
|
|
overlay.setAttribute('aria-modal', 'true');
|
|
overlay.innerHTML = '<button type="button" class="noir-lightbox-close tracked-mono" aria-label="Close">Close</button><img alt="" /><div class="noir-lightbox-caption tracked-mono"></div>';
|
|
document.body.appendChild(overlay);
|
|
overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); });
|
|
overlay.querySelector('.noir-lightbox-close').addEventListener('click', close);
|
|
document.addEventListener('keydown', function(e) {
|
|
if (overlay.getAttribute('aria-hidden') === 'false' && e.key === 'Escape') close();
|
|
});
|
|
return overlay;
|
|
}
|
|
function open(src, alt, caption) {
|
|
var o = ensureOverlay();
|
|
var img = o.querySelector('img');
|
|
img.src = src;
|
|
img.alt = alt || '';
|
|
o.querySelector('.noir-lightbox-caption').textContent = caption || '';
|
|
o.setAttribute('aria-hidden', 'false');
|
|
o.querySelector('.noir-lightbox-close').focus();
|
|
}
|
|
function close() {
|
|
if (!overlay) return;
|
|
overlay.setAttribute('aria-hidden', 'true');
|
|
}
|
|
document.addEventListener('click', function(e) {
|
|
var t = e.target.closest('[data-noir-lightbox-trigger]');
|
|
if (!t) return;
|
|
e.preventDefault();
|
|
open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
var t = document.activeElement;
|
|
if (t && t.matches && t.matches('[data-noir-lightbox-trigger]')) {
|
|
e.preventDefault();
|
|
open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
}
|
|
|
|
// NoirDefault — centred gallery page with thin masthead and dissolved footer.
|
|
templ NoirDefault(data NoirPageData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
@noirHead(data)
|
|
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="default">
|
|
@bn.AdminBypassBanner(data.SiteSettings)
|
|
<header class="w-full hairline-b">
|
|
<div class="max-w-5xl mx-auto px-6">
|
|
@templ.Raw(data.Slots["header"])
|
|
</div>
|
|
</header>
|
|
<main class="flex-grow max-w-5xl mx-auto w-full px-6 py-12">
|
|
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
@templ.Raw(main)
|
|
} else {
|
|
<div class="py-24 text-center">
|
|
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">No photographs assigned to this page.</p>
|
|
</div>
|
|
}
|
|
</main>
|
|
<footer class="w-full mt-auto">
|
|
@templ.Raw(data.Slots["footer"])
|
|
</footer>
|
|
@noirLightboxScript()
|
|
@bn.BodyEnd(data.SiteSettings)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// NoirLanding — edge-to-edge hero, micro caption strip, minimal CTA.
|
|
templ NoirLanding(data NoirPageData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
@noirHead(data)
|
|
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="landing">
|
|
@bn.AdminBypassBanner(data.SiteSettings)
|
|
<section class="w-full">
|
|
@templ.Raw(data.Slots["hero"])
|
|
</section>
|
|
<main class="flex-grow">
|
|
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
<div class="max-w-5xl mx-auto px-6 py-16">
|
|
@templ.Raw(main)
|
|
</div>
|
|
}
|
|
</main>
|
|
<section class="w-full">
|
|
@templ.Raw(data.Slots["cta"])
|
|
</section>
|
|
<footer class="w-full mt-auto">
|
|
@templ.Raw(data.Slots["footer"])
|
|
</footer>
|
|
@noirLightboxScript()
|
|
@bn.BodyEnd(data.SiteSettings)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// NoirArticle — long-form case study with sticky caption rail and image-led prose.
|
|
templ NoirArticle(data NoirPageData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
@noirHead(data)
|
|
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="article">
|
|
@bn.AdminBypassBanner(data.SiteSettings)
|
|
<header class="w-full hairline-b">
|
|
<div class="max-w-5xl mx-auto px-6">
|
|
@templ.Raw(data.Slots["header"])
|
|
</div>
|
|
</header>
|
|
<main class="flex-grow max-w-5xl mx-auto w-full px-6 py-12">
|
|
<div class="grid grid-cols-1 md:grid-cols-[12rem_1fr] gap-8">
|
|
<aside class="noir-meta-rail">
|
|
@templ.Raw(data.Slots["aside"])
|
|
</aside>
|
|
<article class="min-w-0">
|
|
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
@templ.Raw(main)
|
|
} else {
|
|
<div class="py-24 text-center">
|
|
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">No content assigned to this article.</p>
|
|
</div>
|
|
}
|
|
</article>
|
|
</div>
|
|
</main>
|
|
<footer class="w-full mt-auto">
|
|
@templ.Raw(data.Slots["footer"])
|
|
</footer>
|
|
@noirLightboxScript()
|
|
@bn.BodyEnd(data.SiteSettings)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// NoirFullWidth — contact-sheet or lightbox grid, no horizontal padding.
|
|
templ NoirFullWidth(data NoirPageData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
@noirHead(data)
|
|
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="full-width">
|
|
@bn.AdminBypassBanner(data.SiteSettings)
|
|
<header class="w-full hairline-b">
|
|
<div class="max-w-5xl mx-auto px-6">
|
|
@templ.Raw(data.Slots["header"])
|
|
</div>
|
|
</header>
|
|
<main class="flex-grow w-full">
|
|
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
@templ.Raw(main)
|
|
} else {
|
|
<div class="max-w-5xl mx-auto py-24 px-6 text-center">
|
|
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">No photographs assigned to this page.</p>
|
|
</div>
|
|
}
|
|
</main>
|
|
<footer class="w-full mt-auto">
|
|
@templ.Raw(data.Slots["footer"])
|
|
</footer>
|
|
@noirLightboxScript()
|
|
@bn.BodyEnd(data.SiteSettings)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
func RenderNoirDefault(ctx context.Context, doc map[string]any) templ.Component {
|
|
return NoirDefault(parseNoirPageData(doc))
|
|
}
|
|
|
|
func RenderNoirLanding(ctx context.Context, doc map[string]any) templ.Component {
|
|
return NoirLanding(parseNoirPageData(doc))
|
|
}
|
|
|
|
func RenderNoirArticle(ctx context.Context, doc map[string]any) templ.Component {
|
|
return NoirArticle(parseNoirPageData(doc))
|
|
}
|
|
|
|
func RenderNoirFullWidth(ctx context.Context, doc map[string]any) templ.Component {
|
|
return NoirFullWidth(parseNoirPageData(doc))
|
|
}
|