Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2ae2529d4 | ||
|
|
fd2cec8896 | ||
|
|
3eb3540253 | ||
|
|
51c8848a50 | ||
|
|
6e0162b76d | ||
|
|
345741f83f | ||
|
|
27caa1d30a | ||
|
|
490e69716f | ||
|
|
c42354962c | ||
|
|
1283b86ffa | ||
|
|
830f51d047 | ||
|
|
2f7239b76a | ||
|
|
34a9bc61c0 | ||
|
|
7e5fea562f | ||
|
|
523f1fb0ec | ||
|
|
65457ca821 | ||
|
|
0b3013ce81 | ||
|
|
895d76f612 | ||
|
|
06436be10f | ||
|
|
1451f06d9f | ||
|
|
2c0428dce9 | ||
|
|
e3a471a3df | ||
|
|
0a24e8ecff | ||
|
|
d3963dfca2 |
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
|||||||
tmp/
|
tmp/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
*.bnp
|
||||||
|
|||||||
184
BUILD_REPORT.md
@ -1,143 +1,65 @@
|
|||||||
# Noir — Build Report
|
# Noir — Build Report
|
||||||
|
|
||||||
Implementation pass: wave-1 scaffold of the `noir` theme plugin.
|
Wave A pass (WO-TF-022): re-persona from a photography/portfolio theme to the
|
||||||
|
LOCKED **detective / true-crime dossier** direction. Codeless `.bnp`.
|
||||||
|
|
||||||
Plugin slug: `noir`
|
Plugin slug: `noir` · kind: `theme` · version: `0.2.0`
|
||||||
Module path: `git.dev.alexdunmow.com/block/themes/noir`
|
|
||||||
SDK pinned to: `git.dev.alexdunmow.com/block/core v0.11.1`
|
|
||||||
Go directive: `go 1.26.4`
|
|
||||||
Tech style: templ (per spec §11)
|
|
||||||
|
|
||||||
## What landed
|
## Identity
|
||||||
|
|
||||||
### Metadata & build glue
|
Case-file panels, typewriter type, redaction bars, evidence board and film
|
||||||
- `plugin.mod` — name, display_name, scope `@themes`, kind `theme`, version `0.1.0`, categories `["templates", "media"]`, tags array (8 entries, includes the four spec-required minimums), `[compatibility] block_core = ">=0.11.0 <0.12.0"`.
|
grain. High-key gallery by day (ink type, evidence-red accent); darkroom black by
|
||||||
- `go.mod` — pins `block/core v0.11.1` and `templ v0.3.1020`, mirrors gotham's indirect set, no `replace` directives.
|
night (bone type, amber safelight accent). Motion tier MID: JS scroll-reveal
|
||||||
- `Makefile` — local-only targets (`all`, `templ`, `clean`). No `rebuild` target. Default builds `noir.so` via `CGO_ENABLED=1 go build -buildmode=plugin`.
|
(reduced-motion honored, static no-JS fallback) plus the builtin gallery lightbox
|
||||||
- `embed.go` — five canonical `//go:embed` directives plus a `ThemeCSSManifest()` that surfaces `assets/style.css` through `CSSManifest.InputCSSAppend` so the custom utilities (`.tracked-mono`, `.bleed`, `.hairline`, sprocket motif, lightbox overlay) survive Tailwind's content scanner.
|
showpiece (no canvas).
|
||||||
- `registration.go` — exports `var Registration plugin.PluginRegistration` with all functions wired.
|
|
||||||
|
|
||||||
### System & page templates
|
## What shipped
|
||||||
- `tr.RegisterSystemTemplate({Key: "noir", …})` — exactly once.
|
|
||||||
- Four `tr.RegisterPageTemplate("noir", …)` calls with the spec's slot sets:
|
|
||||||
- `default` — `["header", "main", "footer"]`
|
|
||||||
- `landing` — `["hero", "main", "cta", "footer"]`
|
|
||||||
- `article` — `["header", "main", "aside", "footer"]`
|
|
||||||
- `full-width` — `["header", "main", "footer"]`
|
|
||||||
- Each page renderer lives in `template.templ` and consumes the shared `bn.Head` / `bn.AdminBypassBanner` / `bn.BodyEnd` helpers, mirroring gotham.
|
|
||||||
|
|
||||||
### Blocks (6 theme-specific)
|
- **49 mandatory template overrides** in `templates/overrides/noir/` + a
|
||||||
- `noir:lightbox_gallery` — grid + vanilla keyboard-aware lightbox overlay (Esc + Enter/Space, click-outside dismiss, ARIA-modal).
|
`template_overrides` entry per key in `manifest.yaml`. Each re-skins the frozen
|
||||||
- `noir:contact_sheet` — sprocket-framed numbered grid; `data-frame-number` attribute on each frame for UAT §13.9.
|
builtin default, preserving every content field, provider var, custom tag and
|
||||||
- `noir:case_study` — sticky meta rail (client / year / credits) + image stack.
|
`<script>`/`<style>` block verbatim; only styling changes. Legacy button/card
|
||||||
- `noir:caption_strip` — 10px mono full-width strip.
|
field reads reconciled to the new builtin contract (link/style/label, text/media).
|
||||||
- `noir:image_pair` — 50/50 diptych with shared caption.
|
- **7 page templates** (`templates/noir/`): default, full-width, landing, article,
|
||||||
- `noir:footer` — dissolved-rail footer with optional social links.
|
blog-index, contact, auth.
|
||||||
|
- **4 persona blocks** (`blocks/`): `case_file`, `evidence_board`,
|
||||||
|
`redacted_quote`, `case_dossier`. The old photography blocks (lightbox_gallery,
|
||||||
|
contact_sheet, caption_strip, image_pair, footer, case_study) were deleted — the
|
||||||
|
builtins they duplicated are now overrides.
|
||||||
|
- **5 presets** (`presets.json`), all 19 tokens, light + dark intentionally tuned
|
||||||
|
(`mode: both`): Cold Case (default), Evidence Red, Safelight, Interrogation,
|
||||||
|
Redaction.
|
||||||
|
- **Bundled fonts** (`fonts.json`, `assets/fonts/`, OFL): Source Serif 4 (body) and
|
||||||
|
IBM Plex Mono (typewriter heading/mono). Fallback stacks name Special Elite /
|
||||||
|
Courier Prime first for admins. Licenses in `assets/fonts/LICENSES.md`.
|
||||||
|
- **Themed email wrapper** (`templates/email/noir.ninjatpl`): table-based, inline
|
||||||
|
styles, dossier masthead.
|
||||||
|
- **Master pages** (`master_pages.json`) + **seed demo** (`seed/seed.json`,
|
||||||
|
`seed/workflows.json`): "The Cold File" true-crime site — home, about, blog (3
|
||||||
|
case write-ups), contact (form → `case_tips` data table → `row_inserted` →
|
||||||
|
email-admins workflow), login.
|
||||||
|
- **noir CSS vocabulary** injected via `manifest.yaml` `css.input_css_append`:
|
||||||
|
`.noir-heading/-body/-mono/-label`, `.noir-file`, `.noir-btn/-btn-solid/-link`,
|
||||||
|
`.noir-rule/-hairline`, `.noir-stamp`, `.noir-redact`, `.noir-pin`, `.noir-tape`,
|
||||||
|
`.noir-grain`, `.noir-spotlight`, `.noir-reveal`. Kebab-case tokens only; all
|
||||||
|
font-family via `var(--font-heading|body|mono, <fallback>)`.
|
||||||
|
|
||||||
All registered with `Source: "noir"` and the appropriate `blocks.Category*` constant.
|
## Verification
|
||||||
|
|
||||||
### Schemas (6, draft-07)
|
- `cd themes/noir && make build` → `Built noir@0.2.0 (codeless) → noir-0.2.0.bnp`,
|
||||||
- Property names exactly match the Go `content["…"]` reads in each block.
|
exit 0.
|
||||||
- Every `x-editor` value comes from the allowed set:
|
- `cd check-safety && go run . themes/noir` → `32 checks: 19 ok 13 skip -> OK`,
|
||||||
`text`, `media`, `select`, `number`, `array`, `link`.
|
exit 0.
|
||||||
- `lightbox_gallery.columns` uses `x-editor: select` with `enum: [2, 3, 4]` (spec §8).
|
|
||||||
- `case_study.year` uses `x-editor: number` (spec §8).
|
|
||||||
- `case_study.credits` is `array<string>`; `case_study.images` is `array<media>` per spec §8.
|
|
||||||
- `footer.social` is `array<link>` with `text`/`url` fields per spec §8.
|
|
||||||
|
|
||||||
### Template overrides (5)
|
Visual quality is UNVERIFIED until Wave B screenshots. `preview.png` is a stale
|
||||||
- `RegisterTemplateOverride("noir", …)` for `heading`, `text`, `image`, `button`, `card` — exactly five calls, per UAT §3.
|
photography-era image and will be regenerated in Wave B.
|
||||||
- Display headings render with `font-family: var(--font-heading)` and no underline.
|
|
||||||
- Button override is hairline 1px outline, transparent background, hover inverts to `--primary` / `--primary-foreground`.
|
|
||||||
- Card override is transparent with hairline border only.
|
|
||||||
- Image override emits the `.bleed` utility for full-bleed and a `figcaption.tracked-mono` for the mono caption.
|
|
||||||
|
|
||||||
### Email wrapper
|
## Known platform limits (per OVERRIDE-CONTRACT §11/§12, not theme bugs)
|
||||||
- `tr.RegisterEmailWrapper("noir", NoirEmailWrapper)` — pure black canvas, inline 600px column table, Tenor Sans masthead (18px, letter-spacing +0.05em), 16:10 cover image (600×375), mono caption strip with copyright + unsubscribe.
|
|
||||||
- All inline styles; no `<style>` block (one minor `<head><title>` only).
|
|
||||||
- Falls back to hex literals when `EmailContext.Colors` is empty so the email always renders without theme CSS variables.
|
|
||||||
|
|
||||||
### Master pages (2)
|
- `auth-form`, `auth-status`, `password-reset-form`, `page-suggestions` are
|
||||||
- `noir:default-master` — `default`, `article` templates. Blocks in spec order: `navbar` (variant: minimal), `slot` (slotName: main), `noir:caption_strip`, `noir:footer`.
|
compiled builtins whose overrides do not yet dispatch — the stock builtin HTML
|
||||||
- `noir:gallery-master` — `landing`, `full-width` templates. Uses `navbar` variant `dissolved` and adds `noir:contact_sheet_footer` as the footer-rail block. (`noir:contact_sheet_footer` is referenced as a block key per spec §6; it is not currently provisioned as a separate block in this pass — see open items.)
|
renders until the cms fix lands. Authored faithfully regardless.
|
||||||
|
- Provider-backed overrides (navbar/footer menus, featured/related/popular posts,
|
||||||
### Presets (3, all 19 tokens)
|
category-list, breadcrumbs, blog-hero, sidebar-nav, author-bio-hero) render
|
||||||
- `pure-noir` — mode `dark`, single `darkColors` block. Pure black ground, white primary, silver accent.
|
themed chrome but empty live lists under the codeless loader until the cms fix
|
||||||
- `silver-print` — mode `light`, single `lightColors` block. Bone white ground, charcoal type.
|
lands. Authored faithfully to the builtin provider var names.
|
||||||
- `platinum` — mode `both`, both `lightColors` and `darkColors` blocks per UAT §5.
|
|
||||||
- Every value is an HSL triple string (no `hsl(…)` wrapper); verified by `jq` against `^\d+ \d+% \d+%$`.
|
|
||||||
|
|
||||||
### Fonts policy
|
|
||||||
- `fonts.json = []` per `themes/docs/FONTS.md` wave-1 policy.
|
|
||||||
- CSS fallback stacks for `--font-heading` (Tenor Sans → Georgia → serif), `--font-body` (Inter → system sans), `--font-mono` (JetBrains Mono → Consolas → SFMono-Regular → monospace) live in `assets/style.css`.
|
|
||||||
- `RECOMMENDED_FONTS.md` written with picker instructions for the three Google Fonts.
|
|
||||||
- `LICENSES.md` deliberately omitted (nothing bundled), per FONTS.md wave-1 §4.
|
|
||||||
|
|
||||||
### Custom CSS (assets/style.css)
|
|
||||||
- `.tracked-mono` — 10px JetBrains Mono fallback, 0.18em tracking, uppercase, line-height 1.4. Also `.tracked-mono-sm` at 11px.
|
|
||||||
- `.bleed` — `width: 100vw` + `margin-left: calc((100vw - 100%) / 2 * -1)`. The UAT §13.7 grep is satisfied: `calc((100vw - 100%) / 2)` substring appears once.
|
|
||||||
- `.hairline` — 1px border at 40% alpha against `--border`.
|
|
||||||
- `.noir-btn` — hairline outline button with keyboard `:focus-visible` ring on `--ring`.
|
|
||||||
- `.noir-caption-strip` — full-width 10px-strip flex container.
|
|
||||||
- `.noir-sprocket` — sprocket-hole motif via `radial-gradient` pseudo-elements at top and bottom.
|
|
||||||
- Lightbox overlay — `[data-noir-lightbox]` styled `position: fixed; inset: 0; width: 100vw; height: 100vh` with `aria-hidden`-driven visibility, Esc/click-outside dismiss, and a `prefers-reduced-motion` opt-out.
|
|
||||||
|
|
||||||
## Build output
|
|
||||||
|
|
||||||
```
|
|
||||||
$ cd /home/alex/src/blockninja/themes/noir
|
|
||||||
$ go mod tidy # OK, go.sum populated, block/core v0.11.1 resolved
|
|
||||||
$ templ generate # OK, 9 *_templ.go produced
|
|
||||||
$ make # OK
|
|
||||||
$ ls -lh noir.so
|
|
||||||
-rw-rw-r-- 1 alex alex 21M noir.so # ~21 MB, expected for templ + bn helper compile
|
|
||||||
```
|
|
||||||
|
|
||||||
`noir.so` was produced via:
|
|
||||||
|
|
||||||
```
|
|
||||||
CGO_ENABLED=1 go build -buildmode=plugin -ldflags="-s -w" -o noir.so .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Safety check
|
|
||||||
|
|
||||||
```
|
|
||||||
$ cd /home/alex/src/blockninja/check-safety
|
|
||||||
$ go run . /home/alex/src/blockninja/themes/noir \
|
|
||||||
--plugin-dir /home/alex/src/blockninja/themes/noir
|
|
||||||
# … all checks OK or SKIP for noir, exit code 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Notable results:
|
|
||||||
- **Check 2c** (Standalone plugin SDK import boundaries): OK. `go.mod` does not locally replace `block/core`; only `block/core/...` is imported.
|
|
||||||
- **Check 3** (`go vet`, golangci-lint, strict lint): OK for the single noir module.
|
|
||||||
- **Check 6** (No hardcoded colors): OK across `.templ`, `.ninjatpl`, and `.css`. All colour values use `hsl(var(--token))` or HSL triples in `presets.json`.
|
|
||||||
- **Check 11** (No placeholder code): OK.
|
|
||||||
- **Check 17** (No TODO markers): OK.
|
|
||||||
- **Check 21** (Plugin presets validation): OK — presets unmarshal cleanly against `theme.Theme`.
|
|
||||||
- **Check 2e** (Warn on any usage): 30 warnings — same shape as gotham/lcars baseline (the public `BlockFunc` and `MasterPageBlock.Content` signatures are `map[string]any` in the SDK itself, so the warnings are inherent to the API). No failure.
|
|
||||||
|
|
||||||
> Note: the task description references `~/src/blockninja/backend/cmd/check-safety`, but on this checkout the binary lives at `~/src/blockninja/check-safety/`. The equivalent invocation is shown above and exits 0.
|
|
||||||
|
|
||||||
## Open items / deferred
|
|
||||||
|
|
||||||
1. **Real woff2 files** — deferred to wave-2 per FONTS.md §5. `fonts.json = []`, fallbacks in CSS, `RECOMMENDED_FONTS.md` documents the picker path.
|
|
||||||
2. **`noir:contact_sheet_footer` block** — referenced by `noir:gallery-master` (per spec §6) but not implemented as a distinct block in this pass; spec §8 lists six blocks and this would be a seventh. The master page entry is kept (so the seeder can find the block once added) but the gallery footer currently falls back to an empty contact-sheet item array. Build does not fail; the block resolves to the existing `noir:contact_sheet` registration when wired by the host. Track for follow-up.
|
|
||||||
3. **Demo content / "Atelier Vance" seed** — not part of theme-plugin scope in this pass.
|
|
||||||
4. **Marketplace screenshots (6 frames)** — require a running `instance-noir` container; not produced here.
|
|
||||||
5. **Email render testing in Litmus / Apple Mail / Outlook 365** — out of scope without a live SMTP test.
|
|
||||||
6. **`LICENSES.md`** — intentionally omitted per FONTS.md wave-1 §4 (nothing bundled).
|
|
||||||
7. **Lightbox JS hardening** — vanilla keyboard-trap is implemented but a full focus-trap (Tab cycling within the overlay) is left as a wave-2 polish item; current behaviour traps Esc + click-outside + Enter/Space activation.
|
|
||||||
8. **`make rebuild` workflow** — deliberately omitted from the Makefile per task brief; the deploy targets live in `gotham/Makefile` and can be lifted across when the theme is ready to land in the live CMS container.
|
|
||||||
9. **Versioned git tag** — `plugin.mod version = "0.1.0"` is set but no git tag is created (theme directory is not a git repo on its own).
|
|
||||||
|
|
||||||
## Counts
|
|
||||||
|
|
||||||
- Page templates registered: **4** (`default`, `landing`, `article`, `full-width`).
|
|
||||||
- Theme-specific blocks registered: **6** (`lightbox_gallery`, `contact_sheet`, `case_study`, `caption_strip`, `image_pair`, `footer`).
|
|
||||||
- Template overrides registered: **5** (`heading`, `text`, `image`, `button`, `card`).
|
|
||||||
- Email wrappers registered: **1** (`noir`).
|
|
||||||
- Master pages provisioned: **2** (`noir:default-master`, `noir:gallery-master`).
|
|
||||||
- Presets: **3** (`pure-noir`, `silver-print`, `platinum`).
|
|
||||||
- Schemas: **6** (one per theme-specific block).
|
|
||||||
- Bundled fonts: **0** (wave-1 policy).
|
|
||||||
|
|||||||
56
Makefile
@ -1,31 +1,55 @@
|
|||||||
# Noir — build helpers (.so plugin workflow)
|
# noir — build & publish helpers (CODELESS .bnp workflow, WO-WZ-021)
|
||||||
#
|
#
|
||||||
# Local single-shot build:
|
# This theme is a CODELESS .bnp: pure declaration the host runs — blocks
|
||||||
# make # produces noir.so via CGO go build -buildmode=plugin
|
# (blocks/blocks.yaml + .ninjatpl), page/system templates, template overrides,
|
||||||
# make templ # regenerate *_templ.go files
|
# an email wrapper, master pages, presets, fonts and CSS in manifest.yaml. No
|
||||||
|
# Go, no templ, no plugin.wasm, no block/core dependency. `ninja plugin build`
|
||||||
|
# classifies the repo as codeless (no Go at the root) and packs the artifact
|
||||||
|
# with manifest.pb (codeless: true) and NO plugin.wasm.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make build # pack noir-<version>.bnp (codeless — no wasm)
|
||||||
|
# make verify # validate the built .bnp (loader-identical checks)
|
||||||
|
# make archive-check # prove a clean `git archive HEAD` still builds codeless
|
||||||
|
# make publish # ninja plugin publish --bnp (to the active registry)
|
||||||
# make clean # remove build artefacts
|
# make clean # remove build artefacts
|
||||||
|
|
||||||
.PHONY: all clean templ help
|
.PHONY: build verify archive-check publish clean help
|
||||||
|
|
||||||
PLUGIN_NAME := noir
|
PLUGIN_NAME := noir
|
||||||
|
NINJA := ninja
|
||||||
|
BNP := $(PLUGIN_NAME)-$(shell grep '^version' plugin.mod | sed 's/.*"\(.*\)"/\1/').bnp
|
||||||
|
|
||||||
# Default target: build the .so locally.
|
# Pack the codeless .bnp (manifest.pb synthesized from plugin.mod + manifest.yaml
|
||||||
all: $(PLUGIN_NAME).so
|
# + blocks/ + templates/; no wasm compile, no DESCRIBE probe).
|
||||||
|
build:
|
||||||
|
$(NINJA) plugin build --dir .
|
||||||
|
|
||||||
# Local plugin build (no container). Compiles to .so.
|
# Validate the built .bnp exactly as the registry/loader will.
|
||||||
$(PLUGIN_NAME).so: $(wildcard *.go) plugin.mod go.mod
|
verify:
|
||||||
CGO_ENABLED=1 go build -buildmode=plugin -ldflags="-s -w" -o $(PLUGIN_NAME).so .
|
$(NINJA) plugin verify $(BNP)
|
||||||
|
|
||||||
# Regenerate templ Go files locally.
|
# Prove a clean `git archive HEAD` (what publish ships) still packs codeless.
|
||||||
templ:
|
archive-check:
|
||||||
templ generate
|
@T=$$(mktemp -d /tmp/archive-check-$(PLUGIN_NAME).XXXXXX) && \
|
||||||
|
trap 'rm -rf "$$T"' EXIT && \
|
||||||
|
git archive HEAD | tar -x -C "$$T" && \
|
||||||
|
cd "$$T" && $(NINJA) plugin build --dir . && \
|
||||||
|
echo "archive-check OK"
|
||||||
|
|
||||||
|
# Publish the prebuilt .bnp to the active registry.
|
||||||
|
# Pass --host https://my.blockninja.dev for the dev orchestrator.
|
||||||
|
publish: build
|
||||||
|
$(NINJA) plugin publish --bnp $(BNP)
|
||||||
|
|
||||||
# Remove build artefacts.
|
# Remove build artefacts.
|
||||||
clean:
|
clean:
|
||||||
rm -f $(PLUGIN_NAME).so
|
rm -f $(PLUGIN_NAME)-*.bnp plugin.wasm
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@echo " all Build $(PLUGIN_NAME).so (default)"
|
@echo " build Pack the codeless .bnp (no wasm)"
|
||||||
@echo " templ Regenerate *_templ.go files"
|
@echo " verify Validate the built .bnp (loader-identical checks)"
|
||||||
|
@echo " archive-check Prove a clean git archive HEAD packs codeless"
|
||||||
|
@echo " publish Publish the .bnp to the active registry"
|
||||||
@echo " clean Remove build artefacts"
|
@echo " clean Remove build artefacts"
|
||||||
|
|||||||
24
README.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Noir
|
||||||
|
|
||||||
|
A detective's dossier laid out on the desk. Case-file cards, typewriter type, and black redaction bars set the tone, with a faint film grain over the whole surface. By day it reads like a high-key gallery wall. At night it drops to darkroom black. The mood is investigative and a little tense, and the type stays legible throughout.
|
||||||
|
|
||||||
|
## Good for
|
||||||
|
|
||||||
|
We built Noir for storytelling with an edge. Treat these as starting points, not limits. If the mood fits your brand, use it:
|
||||||
|
|
||||||
|
- True-crime podcasts and newsletters
|
||||||
|
- Investigative journalism and long-form reporting
|
||||||
|
- Mystery authors and book launches
|
||||||
|
- Escape rooms and immersive events
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- Five color presets, each with a tuned light mode and a tuned dark mode: Cold Case, Evidence Red, Safelight, Interrogation, and Redaction.
|
||||||
|
- Source Serif 4 for reading and IBM Plex Mono for labels and stamps, both bundled. Fonts stay admin-controlled, so you can swap them from the typography settings.
|
||||||
|
- Four persona blocks: a case dossier, a stamped case-file card, an evidence board with pinned notes, and a redacted quote with blacked-out lines.
|
||||||
|
- A demo site that installs when you activate the theme: a home page, an about page, a short blog, and a contact page whose form emails your admins.
|
||||||
|
- Every core block restyled to match, plus a themed login screen and a themed 404 page.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install from the BlockNinja registry, then pick a preset and assign your fonts in the admin. Activate the demo content if you want a fully built starting point.
|
||||||
@ -1,46 +1,48 @@
|
|||||||
# Noir — Recommended Fonts
|
# Noir — Recommended Fonts
|
||||||
|
|
||||||
Noir ships with `fonts.json = []` per the wave-1 fonts policy
|
Noir is a detective / true-crime dossier. The typographic voice is a typewriter
|
||||||
(`themes/docs/FONTS.md`). No woff2s are bundled in this implementation
|
for the file itself and a quiet reading serif for the case write-up. The theme
|
||||||
pass. The site admin assigns fonts via the typography settings panel.
|
**bundles** two OFL faces so it renders on its intended stack with no admin setup
|
||||||
|
(`fonts.json`, woff2 in `assets/fonts/`, licenses in
|
||||||
|
`assets/fonts/LICENSES.md`):
|
||||||
|
|
||||||
The fallback stacks in `assets/style.css` already approximate the
|
- **IBM Plex Mono** (400, 500) — the shipped typewriter face. Serves both the
|
||||||
intended Noir aesthetic (Tenor Sans display, Inter body, JetBrains
|
heading (typed case-file headers) and mono (evidence labels, case numbers,
|
||||||
Mono captions) using widely available system faces. The picks below
|
stamps) slots by default.
|
||||||
match the spec exactly and are recommended once an admin opens the
|
- **Source Serif 4** (400, 400 italic) — the shipped reading body for case prose.
|
||||||
font picker.
|
|
||||||
|
|
||||||
## Heading
|
Every family flows through `var(--font-heading|body|mono, <fallback>)`; nothing
|
||||||
|
is hardcoded, so an admin can override any slot from the picker. The fallback
|
||||||
|
stacks name the *ideal* typewriter faces first, so assigning them lifts the look
|
||||||
|
further:
|
||||||
|
|
||||||
- **Source**: `google:Tenor Sans`
|
## Heading — typed case-file display
|
||||||
- **Family**: `Tenor Sans`
|
|
||||||
- **Why**: high-contrast modern serif with the silver-print
|
|
||||||
monograph feel called for in spec §3.
|
|
||||||
- **How**: Site Settings → Typography → Heading → Google Fonts →
|
|
||||||
search "Tenor Sans" → Add → Assign to Heading slot.
|
|
||||||
|
|
||||||
## Body
|
- **Ideal**: `google:Special Elite` (Apache 2.0) — worn typewriter, the classic
|
||||||
|
true-crime header face.
|
||||||
|
- **Shipped default**: IBM Plex Mono 500 (bundled, OFL).
|
||||||
|
- **How**: Site Settings → Typography → Heading → Google Fonts → search
|
||||||
|
"Special Elite" → Add → assign to the Heading slot.
|
||||||
|
|
||||||
- **Source**: `google:Inter`
|
## Body — case write-up
|
||||||
- **Family**: `Inter`
|
|
||||||
- **Why**: humanist sans, large x-height, reads cleanly at the
|
|
||||||
generous leading the override uses.
|
|
||||||
- **How**: Site Settings → Typography → Body → Google Fonts →
|
|
||||||
search "Inter" → Add → Assign to Body slot.
|
|
||||||
|
|
||||||
## Mono
|
- **Ideal & shipped**: Source Serif 4 (bundled, OFL) — a calm high-legibility
|
||||||
|
serif for long-form reading.
|
||||||
|
- **Alternative**: `google:Source Serif 4` for the full weight range, or
|
||||||
|
`google:Lora`.
|
||||||
|
- **How**: it is already assigned via the bundled manifest; to change, Site
|
||||||
|
Settings → Typography → Body → pick a serif → assign to the Body slot.
|
||||||
|
|
||||||
- **Source**: `google:JetBrains Mono`
|
## Mono — evidence labels, case numbers, stamps
|
||||||
- **Family**: `JetBrains Mono`
|
|
||||||
- **Why**: tight mono with strong forms at 10–11px, the spec's
|
|
||||||
caption-strip and frame-number face.
|
|
||||||
- **How**: Site Settings → Typography → Mono → Google Fonts →
|
|
||||||
search "JetBrains Mono" → Add → Assign to Mono slot.
|
|
||||||
|
|
||||||
## Notes for wave-2
|
- **Ideal**: `google:Courier Prime` (OFL) — clean courier, the report-typewriter
|
||||||
|
face.
|
||||||
|
- **Shipped default**: IBM Plex Mono 400 (bundled, OFL).
|
||||||
|
- **How**: Site Settings → Typography → Mono → Google Fonts → search
|
||||||
|
"Courier Prime" → Add → assign to the Mono slot.
|
||||||
|
|
||||||
Once licensed woff2s are commissioned (e.g. a darkroom-grade display
|
## Licensing
|
||||||
face), drop them into `assets/fonts/web/` and declare them in
|
|
||||||
`fonts.json`. The CMS will emit `@font-face` blocks automatically; no
|
All bundled woff2 files are SIL OFL 1.1 latin subsets. See
|
||||||
`CSSManifest.InputCSSAppend` changes required. Add the licence to a
|
`assets/fonts/LICENSES.md`. Special Elite is Apache 2.0 and is *not* bundled — it
|
||||||
new `LICENSES.md` at the theme root and remove this `RECOMMENDED_FONTS.md`.
|
is only named as an admin-assignable first choice in the fallback stack.
|
||||||
|
|||||||
26
RECOMMENDED_ICONS.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Recommended Icons — Noir
|
||||||
|
|
||||||
|
This theme references icons from a single bundled pack. `plugin.mod` declares it
|
||||||
|
in `required_icon_packs`; until the loader auto-installs declared packs, an admin
|
||||||
|
should confirm it is enabled before activation.
|
||||||
|
|
||||||
|
## Required pack
|
||||||
|
|
||||||
|
- **lucide** — ships bundled with the CMS (v0.460). Enabled by default. No action
|
||||||
|
needed in most installs; if icons render as empty boxes, open `/admin/icons`
|
||||||
|
and confirm **Lucide** is installed and enabled.
|
||||||
|
|
||||||
|
## Icons used by the theme chrome and overrides
|
||||||
|
|
||||||
|
All via the sprite pipeline (`<svg><use href="/icons/lucide.svg#<name>"/></svg>`
|
||||||
|
or the `::lucide:<name>::` shorthand):
|
||||||
|
|
||||||
|
`arrow-right`, `check`, `chevron-down`, `chevron-left`, `chevron-right`, `x`,
|
||||||
|
`play`, `download`, `navigation`, `map-pin`, `star`, `flag`, `globe`, `search`,
|
||||||
|
`file-text`, `map-pin`.
|
||||||
|
|
||||||
|
Block content that stores an icon (feature-grid items, social links, contact
|
||||||
|
channels, timeline) may reference any installed pack as a `pack:name` string; the
|
||||||
|
schemas default to `lucide` so the theme renders without extra setup. Social
|
||||||
|
network glyphs (X, LinkedIn, GitHub) resolve from the bundled `simple-icons`
|
||||||
|
pack, which also ships with the CMS.
|
||||||
21
assets/fonts/LICENSES.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Bundled font licenses
|
||||||
|
|
||||||
|
All bundled woff2 files in this directory are latin-subset builds redistributed
|
||||||
|
under the SIL Open Font License 1.1 (OFL). No modifications were made beyond
|
||||||
|
subsetting to the latin range.
|
||||||
|
|
||||||
|
| Family | Files | License | Source |
|
||||||
|
|--------|-------|---------|--------|
|
||||||
|
| Source Serif 4 | source-serif-4-400.woff2, source-serif-4-400-italic.woff2 | SIL OFL 1.1 | Adobe / Google Fonts |
|
||||||
|
| IBM Plex Mono | ibm-plex-mono-400.woff2, ibm-plex-mono-500.woff2 | SIL OFL 1.1 | IBM / Google Fonts |
|
||||||
|
|
||||||
|
Full OFL text: `OFL-SourceSerif4.txt` in this directory and https://openfontlicense.org/.
|
||||||
|
|
||||||
|
These are the theme's shipped defaults for the typewriter-and-dossier stack. The
|
||||||
|
theme's fallback stacks name the ideal typewriter faces first — Special Elite
|
||||||
|
(Apache 2.0) and Courier Prime (OFL) — which an admin can assign from the Google
|
||||||
|
Fonts tab; IBM Plex Mono is the bundled fallback for both the heading (typed
|
||||||
|
display) and mono (evidence labels) slots, and Source Serif 4 is the reading
|
||||||
|
body. Font-family is admin-controlled: these families appear as `source=template`
|
||||||
|
rows in the fonts picker and are consumed only via
|
||||||
|
`var(--font-heading|body|mono, <fallback>)`.
|
||||||
24
assets/fonts/OFL-SourceSerif4.txt
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
Bundled fonts — license record (SIL Open Font License, Version 1.1)
|
||||||
|
====================================================================
|
||||||
|
|
||||||
|
The woff2 files in this directory are Latin subsets of the following
|
||||||
|
open-source families, both distributed under the SIL Open Font License 1.1
|
||||||
|
(https://openfontlicense.org). Subsetting and format conversion (TTF to
|
||||||
|
woff2) are expressly permitted by the OFL; the derived subsets remain under
|
||||||
|
the same license.
|
||||||
|
|
||||||
|
Playfair Display
|
||||||
|
playfair-display-700.woff2, playfair-display-900-italic.woff2
|
||||||
|
Copyright (c) Claus Eggers Sorensen (https://www.forthehearts.net), with
|
||||||
|
Reserved Font Name "Playfair Display". Display face for masthead wordmarks
|
||||||
|
and headlines.
|
||||||
|
|
||||||
|
Source Serif 4
|
||||||
|
source-serif-4-400.woff2, source-serif-4-400-italic.woff2
|
||||||
|
Copyright (c) Adobe (https://github.com/adobe-fonts/source-serif), with
|
||||||
|
Reserved Font Name "Source Serif". Long-read body face; italic for
|
||||||
|
marginalia and figure captions.
|
||||||
|
|
||||||
|
The full SIL Open Font License 1.1 text applies to each family and is
|
||||||
|
available at https://openfontlicense.org. This file records the license and
|
||||||
|
copyright attribution as required for redistribution under the OFL.
|
||||||
BIN
assets/fonts/ibm-plex-mono-400.woff2
Normal file
BIN
assets/fonts/ibm-plex-mono-500.woff2
Normal file
BIN
assets/fonts/source-serif-4-400-italic.woff2
Normal file
BIN
assets/fonts/source-serif-4-400.woff2
Normal file
234
assets/style.css
@ -1,234 +0,0 @@
|
|||||||
/* ============================================================
|
|
||||||
Noir Theme — Silver-on-black photography
|
|
||||||
Custom utility CSS injected via CSSManifest.InputCSSAppend.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
/* --- Typography slots ---------------------------------------- */
|
|
||||||
/* Body & page typography flow through the theme font variables.
|
|
||||||
Fallback stacks reflect the spec's intended faces (Tenor Sans,
|
|
||||||
Inter, JetBrains Mono) without hardcoding the family name. */
|
|
||||||
|
|
||||||
.noir-page {
|
|
||||||
font-family: var(--font-body, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-display {
|
|
||||||
font-family: var(--font-heading, "Tenor Sans", Georgia, serif);
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- .tracked-mono ------------------------------------------- */
|
|
||||||
/* 10px JetBrains Mono, +60 tracking equivalent (0.18em), uppercase.
|
|
||||||
Used for captions, frame numbers, and the caption strip. */
|
|
||||||
|
|
||||||
.tracked-mono {
|
|
||||||
font-family: var(--font-mono, "JetBrains Mono", "Consolas", "SFMono-Regular", monospace);
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracked-mono-sm {
|
|
||||||
font-family: var(--font-mono, "JetBrains Mono", "Consolas", "SFMono-Regular", monospace);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- .bleed -------------------------------------------------- */
|
|
||||||
/* Edge-to-edge utility — breaks out of a max-width container. */
|
|
||||||
|
|
||||||
.bleed {
|
|
||||||
width: 100vw;
|
|
||||||
margin-left: calc((100vw - 100%) / 2 * -1);
|
|
||||||
margin-right: calc((100vw - 100%) / 2 * -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- .hairline ----------------------------------------------- */
|
|
||||||
/* 1px almost-invisible border using the border token at 40% alpha. */
|
|
||||||
|
|
||||||
.hairline {
|
|
||||||
border: 1px solid hsl(var(--border) / 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hairline-t { border-top: 1px solid hsl(var(--border) / 0.4); }
|
|
||||||
.hairline-b { border-bottom: 1px solid hsl(var(--border) / 0.4); }
|
|
||||||
|
|
||||||
/* --- Surface tokens used by overrides ------------------------ */
|
|
||||||
|
|
||||||
.noir-surface {
|
|
||||||
background-color: hsl(var(--background));
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-card {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid hsl(var(--border) / 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons render as hairline outlines that invert on hover.
|
|
||||||
Override block uses these classes directly. */
|
|
||||||
|
|
||||||
.noir-btn {
|
|
||||||
display: inline-block;
|
|
||||||
background-color: transparent;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
border: 1px solid hsl(var(--foreground));
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
font-family: var(--font-mono, "JetBrains Mono", "Consolas", "SFMono-Regular", monospace);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background-color 0.18s ease, color 0.18s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-btn:hover,
|
|
||||||
.noir-btn:focus-visible {
|
|
||||||
background-color: hsl(var(--primary));
|
|
||||||
color: hsl(var(--primary-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-btn:focus-visible {
|
|
||||||
outline: 2px solid hsl(var(--ring));
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Caption strip ------------------------------------------- */
|
|
||||||
|
|
||||||
.noir-caption-strip {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.625rem 1.5rem;
|
|
||||||
border-top: 1px solid hsl(var(--border) / 0.4);
|
|
||||||
border-bottom: 1px solid hsl(var(--border) / 0.4);
|
|
||||||
background-color: hsl(var(--background));
|
|
||||||
color: hsl(var(--mutedForeground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Contact sheet sprocket motif ---------------------------- */
|
|
||||||
|
|
||||||
.noir-sprocket {
|
|
||||||
position: relative;
|
|
||||||
background-color: hsl(var(--card));
|
|
||||||
border: 1px solid hsl(var(--border) / 0.4);
|
|
||||||
padding: 1.25rem 0.75rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-sprocket::before,
|
|
||||||
.noir-sprocket::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 0.5rem;
|
|
||||||
/* Sprocket holes drawn as repeating radial-gradient circles
|
|
||||||
using the muted foreground token at low alpha. */
|
|
||||||
background-image: radial-gradient(
|
|
||||||
circle,
|
|
||||||
hsl(var(--mutedForeground) / 0.85) 0,
|
|
||||||
hsl(var(--mutedForeground) / 0.85) 25%,
|
|
||||||
transparent 27%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
background-size: 1rem 0.5rem;
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
background-position: 0 50%;
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-sprocket::before { top: 0; }
|
|
||||||
.noir-sprocket::after { bottom: 0; }
|
|
||||||
|
|
||||||
.noir-frame-number {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.4rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Lightbox overlay ---------------------------------------- */
|
|
||||||
|
|
||||||
[data-noir-lightbox] {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: none;
|
|
||||||
z-index: 9999;
|
|
||||||
background-color: hsl(var(--background));
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.5rem;
|
|
||||||
animation: noirFadeIn 180ms ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-noir-lightbox][aria-hidden="false"] {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-noir-lightbox] img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 80vh;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-noir-lightbox] .noir-lightbox-caption {
|
|
||||||
margin-top: 1rem;
|
|
||||||
color: hsl(var(--mutedForeground));
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-noir-lightbox] .noir-lightbox-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes noirFadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Sticky case-study meta rail ----------------------------- */
|
|
||||||
|
|
||||||
.noir-meta-rail {
|
|
||||||
position: sticky;
|
|
||||||
top: 1.5rem;
|
|
||||||
align-self: start;
|
|
||||||
color: hsl(var(--mutedForeground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Image full-bleed override ------------------------------- */
|
|
||||||
|
|
||||||
.noir-figure {
|
|
||||||
margin: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-figure img {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noir-figure figcaption {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
color: hsl(var(--mutedForeground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Reduced motion ------------------------------------------ */
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
[data-noir-lightbox] { animation: none; }
|
|
||||||
}
|
|
||||||
34
blocks/blocks.yaml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Noir theme block definitions (codeless .bnp, WO-TF-022).
|
||||||
|
# Detective / true-crime persona furniture ONLY. Everything that duplicates a
|
||||||
|
# builtin (galleries, footers, dividers, captions, image pairs) is shipped as a
|
||||||
|
# template OVERRIDE of the builtin key instead (see templates/overrides/noir/).
|
||||||
|
#
|
||||||
|
# Keys are fully qualified (noir:<key>) because the codeless loader registers
|
||||||
|
# definitions verbatim — there is no PluginBlockRegistry prefixing in the
|
||||||
|
# codeless path. The qualified keys match the data-block attributes, the
|
||||||
|
# master_pages.json references and the seed.
|
||||||
|
blocks:
|
||||||
|
- key: noir:case_file
|
||||||
|
title: Case File
|
||||||
|
description: A dossier card — case number, status stamp, subject and a set of field/value evidence rows.
|
||||||
|
category: content
|
||||||
|
schema: case_file.schema.json
|
||||||
|
template: case_file.ninjatpl
|
||||||
|
- key: noir:evidence_board
|
||||||
|
title: Evidence Board
|
||||||
|
description: A pinned board of evidence — photographs and notes with push-pins and case tags.
|
||||||
|
category: content
|
||||||
|
schema: evidence_board.schema.json
|
||||||
|
template: evidence_board.ninjatpl
|
||||||
|
- key: noir:redacted_quote
|
||||||
|
title: Redacted Quote
|
||||||
|
description: A transcript pull quote with a redaction bar over the withheld words that lifts on hover or focus.
|
||||||
|
category: content
|
||||||
|
schema: redacted_quote.schema.json
|
||||||
|
template: redacted_quote.ninjatpl
|
||||||
|
- key: noir:case_dossier
|
||||||
|
title: Case Dossier
|
||||||
|
description: Long-form case spread with a sticky evidence rail and typed prose.
|
||||||
|
category: layout
|
||||||
|
schema: case_dossier.schema.json
|
||||||
|
template: case_dossier.ninjatpl
|
||||||
26
blocks/case_dossier.ninjatpl
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<section data-block="noir:case_dossier" class="py-12">
|
||||||
|
<div class="mx-auto grid max-w-6xl grid-cols-1 gap-10 px-4 md:grid-cols-[16rem_1fr]">
|
||||||
|
<aside class="self-start md:sticky md:top-6">
|
||||||
|
<div class="noir-file p-5">
|
||||||
|
{% if status == "cold" %}<span class="noir-stamp" style="color:hsl(var(--muted-foreground));">Cold</span>{% elif status == "closed" %}<span class="noir-stamp" style="color:hsl(var(--destructive));">Closed</span>{% elif status == "active" %}<span class="noir-stamp" style="color:hsl(var(--primary));">Active</span>{% else %}<span class="noir-stamp" style="color:hsl(var(--primary));">Open</span>{% endif %}
|
||||||
|
<dl class="mt-6 space-y-4">
|
||||||
|
{% if caseNumber %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Case No.</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ caseNumber }}</dd></div>{% endif %}
|
||||||
|
{% if opened %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Opened</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ opened }}</dd></div>{% endif %}
|
||||||
|
{% if location %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Location</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ location }}</dd></div>{% endif %}
|
||||||
|
{% if lead %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Lead</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ lead }}</dd></div>{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="min-w-0">
|
||||||
|
{% if title %}<h1 class="noir-heading text-3xl leading-tight md:text-4xl" style="color:hsl(var(--foreground));">{{ title }}</h1>{% endif %}
|
||||||
|
{% if summary %}<p class="noir-body mt-6 text-lg leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ summary }}</p>{% endif %}
|
||||||
|
{% if images %}
|
||||||
|
<div class="mt-8 space-y-8">
|
||||||
|
{% for img in images %}{% if img %}<figure class="noir-file p-2"><div class="overflow-hidden bg-muted">{% img img alt=title|default:"" class="block h-auto w-full object-cover" %}</div></figure>{% endif %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-8 noir-hairline p-12 text-center noir-label" style="color:hsl(var(--muted-foreground));">Add photographs to the case spread.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
60
blocks/case_dossier.schema.json
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Case Dossier",
|
||||||
|
"description": "Long-form case spread with a sticky evidence rail and typed prose.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Title",
|
||||||
|
"description": "Case title.",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"caseNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Case Number",
|
||||||
|
"description": "File reference (e.g. 'CF-0451').",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Status",
|
||||||
|
"description": "Status stamp shown on the rail.",
|
||||||
|
"x-editor": "select",
|
||||||
|
"enum": ["open", "active", "cold", "closed"],
|
||||||
|
"default": "open"
|
||||||
|
},
|
||||||
|
"opened": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Opened",
|
||||||
|
"description": "When the case was opened (e.g. 'March 1998').",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Location",
|
||||||
|
"description": "Case location.",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"lead": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Lead Investigator",
|
||||||
|
"description": "Name of the lead on the file.",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Summary",
|
||||||
|
"description": "Opening summary of the case.",
|
||||||
|
"x-editor": "textarea"
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"type": "array",
|
||||||
|
"title": "Images",
|
||||||
|
"description": "Photographs in the case spread.",
|
||||||
|
"x-editor": "array",
|
||||||
|
"default": [],
|
||||||
|
"items": { "type": "string", "x-editor": "media" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
blocks/case_file.ninjatpl
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<section data-block="noir:case_file" class="py-10">
|
||||||
|
<div class="mx-auto max-w-3xl px-4">
|
||||||
|
<article class="noir-file noir-grain p-6 md:p-8">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
{% if caseNumber %}<div class="noir-label" style="color:hsl(var(--primary));">Case No. {{ caseNumber }}</div>{% endif %}
|
||||||
|
{% if subject %}<h3 class="noir-heading mt-2 text-2xl md:text-3xl" style="color:hsl(var(--foreground));">{{ subject }}</h3>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if status == "cold" %}<span class="noir-stamp" style="color:hsl(var(--muted-foreground));">Cold</span>{% elif status == "closed" %}<span class="noir-stamp" style="color:hsl(var(--destructive));">Closed</span>{% elif status == "active" %}<span class="noir-stamp" style="color:hsl(var(--primary));">Active</span>{% else %}<span class="noir-stamp" style="color:hsl(var(--primary));">Open</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if summary %}<p class="noir-body mt-4 leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ summary }}</p>{% endif %}
|
||||||
|
{% if fields %}
|
||||||
|
<dl class="mt-6 border-t border-[hsl(var(--border))]">
|
||||||
|
{% for row in fields %}
|
||||||
|
<div class="flex items-baseline justify-between gap-4 border-b border-[hsl(var(--border))] py-2">
|
||||||
|
<dt class="noir-label" style="color:hsl(var(--muted-foreground));">{{ row.label }}</dt>
|
||||||
|
<dd class="noir-mono text-right text-sm" style="color:hsl(var(--foreground));">{{ row.value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
49
blocks/case_file.schema.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Case File",
|
||||||
|
"description": "A dossier card — case number, status stamp, subject and a set of field/value evidence rows.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"caseNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Case Number",
|
||||||
|
"description": "File reference (e.g. 'CF-0451').",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Subject",
|
||||||
|
"description": "The name or subject of the case.",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Status",
|
||||||
|
"description": "Status stamp shown on the file.",
|
||||||
|
"x-editor": "select",
|
||||||
|
"enum": ["open", "active", "cold", "closed"],
|
||||||
|
"default": "open"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Summary",
|
||||||
|
"description": "Short case summary.",
|
||||||
|
"x-editor": "textarea"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "array",
|
||||||
|
"title": "Evidence Rows",
|
||||||
|
"description": "Field/value rows shown as a dossier record.",
|
||||||
|
"x-editor": "array",
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": { "type": "string", "title": "Field", "x-editor": "text" },
|
||||||
|
"value": { "type": "string", "title": "Value", "x-editor": "text" }
|
||||||
|
},
|
||||||
|
"required": ["label", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
blocks/evidence_board.ninjatpl
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<section data-block="noir:evidence_board" class="py-12">
|
||||||
|
<div class="mx-auto max-w-6xl px-4">
|
||||||
|
{% if heading %}<h2 class="noir-heading text-center text-2xl md:text-3xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
|
||||||
|
{% if intro %}<p class="noir-body mx-auto mt-3 max-w-2xl text-center leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
|
||||||
|
{% if items %}
|
||||||
|
<div class="mt-14 grid grid-cols-1 gap-x-8 gap-y-14 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{% for item in items %}
|
||||||
|
<figure class="noir-pin noir-file p-3">
|
||||||
|
{% if item.image %}<div class="overflow-hidden bg-muted grayscale">{% img item.image alt=item.label|default:"" class="aspect-[4/5] w-full object-cover" %}</div>{% else %}<div class="aspect-[4/5] w-full noir-hairline" style="background-color:hsl(var(--muted));"></div>{% endif %}
|
||||||
|
{% if item.label %}<figcaption class="noir-label mt-3" style="color:hsl(var(--primary));">{{ item.label }}</figcaption>{% endif %}
|
||||||
|
{% if item.note %}<p class="noir-mono mt-1 text-sm leading-snug" style="color:hsl(var(--muted-foreground));">{{ item.note }}</p>{% endif %}
|
||||||
|
</figure>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-10 noir-hairline p-12 text-center noir-label" style="color:hsl(var(--muted-foreground));">Pin evidence to the board.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
36
blocks/evidence_board.schema.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Evidence Board",
|
||||||
|
"description": "A pinned board of evidence — photographs and notes with push-pins and case tags.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"heading": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Heading",
|
||||||
|
"description": "Board heading.",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Intro",
|
||||||
|
"description": "Short introduction beneath the heading.",
|
||||||
|
"x-editor": "textarea"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"title": "Evidence",
|
||||||
|
"description": "Pinned evidence items.",
|
||||||
|
"x-editor": "array",
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"image": { "type": "string", "title": "Photograph", "x-editor": "media" },
|
||||||
|
"label": { "type": "string", "title": "Tag", "x-editor": "text" },
|
||||||
|
"note": { "type": "string", "title": "Note", "x-editor": "text" }
|
||||||
|
},
|
||||||
|
"required": ["image"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
blocks/redacted_quote.ninjatpl
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<figure data-block="noir:redacted_quote" class="my-10">
|
||||||
|
<div class="mx-auto max-w-3xl px-4">
|
||||||
|
<div class="noir-file p-8 md:p-10">
|
||||||
|
{% if label %}<div class="noir-label" style="color:hsl(var(--primary));">{{ label }}</div>{% else %}<div class="noir-label" style="color:hsl(var(--primary));">Transcript excerpt</div>{% endif %}
|
||||||
|
{% if quote %}<blockquote class="noir-body mt-4 text-xl leading-relaxed md:text-2xl" style="color:hsl(var(--foreground));">{{ quote }}{% if redacted %} <span class="noir-redact" tabindex="0" role="button" aria-label="Redacted — reveal">{{ redacted }}</span>{% endif %}</blockquote>{% endif %}
|
||||||
|
{% if attribution or source %}
|
||||||
|
<figcaption class="mt-6">
|
||||||
|
{% if attribution %}<div class="noir-label" style="color:hsl(var(--foreground));">{{ attribution }}</div>{% endif %}
|
||||||
|
{% if source %}<div class="noir-mono mt-1 text-sm" style="color:hsl(var(--muted-foreground));">{{ source }}</div>{% endif %}
|
||||||
|
</figcaption>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
38
blocks/redacted_quote.schema.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Redacted Quote",
|
||||||
|
"description": "A transcript pull quote with a redaction bar over the withheld words that lifts on hover or focus.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Label",
|
||||||
|
"description": "Small label above the quote (defaults to 'Transcript excerpt').",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"quote": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Quote",
|
||||||
|
"description": "The visible transcript text.",
|
||||||
|
"x-editor": "textarea"
|
||||||
|
},
|
||||||
|
"redacted": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Redacted Phrase",
|
||||||
|
"description": "Withheld words rendered as a redaction bar that lifts on hover or focus.",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"attribution": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Attribution",
|
||||||
|
"description": "Who said it.",
|
||||||
|
"x-editor": "text"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Source",
|
||||||
|
"description": "Where the excerpt is from.",
|
||||||
|
"x-editor": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,36 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CaptionStripBlockMeta defines the Noir caption strip block.
|
|
||||||
var CaptionStripBlockMeta = blocks.BlockMeta{
|
|
||||||
Key: "caption_strip",
|
|
||||||
Title: "Caption Strip",
|
|
||||||
Description: "Full-width 10px mono caption strip with a label on the left and supporting text on the right.",
|
|
||||||
Source: "noir",
|
|
||||||
Category: blocks.CategoryNavigation,
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptionStripBlock renders a thin caption strip.
|
|
||||||
// Content shape: {"label":"INDEX","right":"© Studio"}
|
|
||||||
func CaptionStripBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
data := CaptionStripData{
|
|
||||||
Label: getString(content, "label"),
|
|
||||||
Right: getString(content, "right"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = captionStripComponent(data).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptionStripData holds the parsed view-model.
|
|
||||||
type CaptionStripData struct {
|
|
||||||
Label string
|
|
||||||
Right string
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// captionStripComponent renders the 10px mono caption strip.
|
|
||||||
templ captionStripComponent(data CaptionStripData) {
|
|
||||||
<div data-block="noir:caption_strip" class="noir-caption-strip">
|
|
||||||
<span class="tracked-mono">{ data.Label }</span>
|
|
||||||
<span class="tracked-mono">{ data.Right }</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
// captionStripComponent renders the 10px mono caption strip.
|
|
||||||
func captionStripComponent(data CaptionStripData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div data-block=\"noir:caption_strip\" class=\"noir-caption-strip\"><span class=\"tracked-mono\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 string
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `caption_strip.templ`, Line: 6, Col: 41}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"tracked-mono\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Right)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `caption_strip.templ`, Line: 7, Col: 41}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CaseStudyBlockMeta defines the Noir long-form project case study block.
|
|
||||||
var CaseStudyBlockMeta = blocks.BlockMeta{
|
|
||||||
Key: "case_study",
|
|
||||||
Title: "Project Case Study",
|
|
||||||
Description: "Long-form project spread with sticky meta rail and image-led prose.",
|
|
||||||
Source: "noir",
|
|
||||||
Category: blocks.CategoryLayout,
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaseStudyBlock renders a project case study.
|
|
||||||
// Content shape: {"title":"...","client":"...","year":2025,"credits":["...","..."],"images":["...","..."]}
|
|
||||||
func CaseStudyBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
data := CaseStudyData{
|
|
||||||
Title: getString(content, "title"),
|
|
||||||
Client: getString(content, "client"),
|
|
||||||
Year: getInt(content, "year", 0),
|
|
||||||
Credits: getStringSlice(content, "credits"),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, img := range getStringSlice(content, "images") {
|
|
||||||
if img == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data.Images = append(data.Images, blocks.ResolveMediaPath(img))
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = caseStudyComponent(data).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaseStudyData holds the parsed view-model for the case study block.
|
|
||||||
type CaseStudyData struct {
|
|
||||||
Title string
|
|
||||||
Client string
|
|
||||||
Year int
|
|
||||||
Credits []string
|
|
||||||
Images []string
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// caseStudyComponent renders a project case-study spread with a sticky meta rail.
|
|
||||||
templ caseStudyComponent(data CaseStudyData) {
|
|
||||||
<section data-block="noir:case_study" class="py-12">
|
|
||||||
<div class="max-w-6xl mx-auto px-6 grid grid-cols-1 md:grid-cols-[14rem_1fr] gap-10">
|
|
||||||
<aside class="noir-meta-rail tracked-mono space-y-3">
|
|
||||||
if data.Client != "" {
|
|
||||||
<div>
|
|
||||||
<div style="color: hsl(var(--mutedForeground));">Client</div>
|
|
||||||
<div class="mt-1" style="color: hsl(var(--foreground));">{ data.Client }</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if data.Year > 0 {
|
|
||||||
<div>
|
|
||||||
<div style="color: hsl(var(--mutedForeground));">Year</div>
|
|
||||||
<div class="mt-1" style="color: hsl(var(--foreground));">{ intToString(data.Year) }</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if len(data.Credits) > 0 {
|
|
||||||
<div>
|
|
||||||
<div style="color: hsl(var(--mutedForeground));">Credits</div>
|
|
||||||
<ul class="mt-1 space-y-1" style="color: hsl(var(--foreground));">
|
|
||||||
for _, credit := range data.Credits {
|
|
||||||
<li>{ credit }</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</aside>
|
|
||||||
<div class="min-w-0">
|
|
||||||
if data.Title != "" {
|
|
||||||
<h1 class="noir-display mb-10" style="font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; color: hsl(var(--foreground));">
|
|
||||||
{ data.Title }
|
|
||||||
</h1>
|
|
||||||
}
|
|
||||||
if len(data.Images) > 0 {
|
|
||||||
<div class="space-y-8">
|
|
||||||
for _, img := range data.Images {
|
|
||||||
<figure class="noir-figure">
|
|
||||||
<img src={ img } alt={ data.Title } loading="lazy" class="w-full h-auto block"/>
|
|
||||||
</figure>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="hairline p-12 text-center tracked-mono" style="color: hsl(var(--mutedForeground));">
|
|
||||||
Add photographs to the case study spread.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
// caseStudyComponent renders a project case-study spread with a sticky meta rail.
|
|
||||||
func caseStudyComponent(data CaseStudyData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:case_study\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6 grid grid-cols-1 md:grid-cols-[14rem_1fr] gap-10\"><aside class=\"noir-meta-rail tracked-mono space-y-3\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if data.Client != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div><div style=\"color: hsl(var(--mutedForeground));\">Client</div><div class=\"mt-1\" style=\"color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 string
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Client)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 11, Col: 76}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if data.Year > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><div style=\"color: hsl(var(--mutedForeground));\">Year</div><div class=\"mt-1\" style=\"color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(intToString(data.Year))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 17, Col: 87}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(data.Credits) > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div><div style=\"color: hsl(var(--mutedForeground));\">Credits</div><ul class=\"mt-1 space-y-1\" style=\"color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, credit := range data.Credits {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<li>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(credit)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 25, Col: 20}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</li>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</aside><div class=\"min-w-0\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if data.Title != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h1 class=\"noir-display mb-10\" style=\"font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 34, Col: 18}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h1>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(data.Images) > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"space-y-8\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, img := range data.Images {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<figure class=\"noir-figure\"><img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(img)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 41, Col: 22}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 41, Col: 41}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" loading=\"lazy\" class=\"w-full h-auto block\"></figure>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"hairline p-12 text-center tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Add photographs to the case study spread.</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div></section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ContactSheetBlockMeta defines the Noir contact sheet block.
|
|
||||||
var ContactSheetBlockMeta = blocks.BlockMeta{
|
|
||||||
Key: "contact_sheet",
|
|
||||||
Title: "Contact Sheet",
|
|
||||||
Description: "Numbered photograph frames with sprocket motif evoking a darkroom contact sheet.",
|
|
||||||
Source: "noir",
|
|
||||||
Category: blocks.CategoryContent,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContactSheetBlock renders a contact-sheet grid.
|
|
||||||
// Content shape: {"items":[{"image":"...","frame":"01","label":"..."}, ...]}
|
|
||||||
func ContactSheetBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
items := getSlice(content, "items")
|
|
||||||
|
|
||||||
var frames []ContactFrame
|
|
||||||
for i, item := range items {
|
|
||||||
frame := getString(item, "frame")
|
|
||||||
if frame == "" {
|
|
||||||
frame = padFrame(i + 1)
|
|
||||||
}
|
|
||||||
frames = append(frames, ContactFrame{
|
|
||||||
Image: blocks.ResolveMediaPath(getString(item, "image")),
|
|
||||||
Frame: frame,
|
|
||||||
Label: getString(item, "label"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = contactSheetComponent(frames).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContactFrame represents a single numbered frame in the contact sheet.
|
|
||||||
type ContactFrame struct {
|
|
||||||
Image string
|
|
||||||
Frame string
|
|
||||||
Label string
|
|
||||||
}
|
|
||||||
|
|
||||||
// padFrame zero-pads a frame number to two digits ("01", "02", ..., "10").
|
|
||||||
func padFrame(n int) string {
|
|
||||||
if n < 10 {
|
|
||||||
return "0" + intToString(n)
|
|
||||||
}
|
|
||||||
return intToString(n)
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// contactSheetComponent renders the contact sheet grid with sprocket frames.
|
|
||||||
templ contactSheetComponent(frames []ContactFrame) {
|
|
||||||
<section data-block="noir:contact_sheet" class="py-12">
|
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
|
||||||
if len(frames) == 0 {
|
|
||||||
<div class="hairline p-12 text-center tracked-mono" style="color: hsl(var(--mutedForeground));">
|
|
||||||
Add frames to populate the contact sheet.
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
for _, frame := range frames {
|
|
||||||
<figure class="noir-sprocket noir-figure" data-frame-number={ frame.Frame }>
|
|
||||||
<span class="noir-frame-number tracked-mono" style="color: hsl(var(--mutedForeground));">
|
|
||||||
{ frame.Frame }
|
|
||||||
</span>
|
|
||||||
if frame.Image != "" {
|
|
||||||
<img
|
|
||||||
src={ frame.Image }
|
|
||||||
alt={ frame.Label }
|
|
||||||
loading="lazy"
|
|
||||||
class="w-full h-auto block mt-2"
|
|
||||||
/>
|
|
||||||
} else {
|
|
||||||
<div class="aspect-square hairline" style="background-color: hsl(var(--muted));"></div>
|
|
||||||
}
|
|
||||||
if frame.Label != "" {
|
|
||||||
<figcaption class="tracked-mono mt-2" style="color: hsl(var(--mutedForeground));">
|
|
||||||
{ frame.Label }
|
|
||||||
</figcaption>
|
|
||||||
}
|
|
||||||
</figure>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
// contactSheetComponent renders the contact sheet grid with sprocket frames.
|
|
||||||
func contactSheetComponent(frames []ContactFrame) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:contact_sheet\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if len(frames) == 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"hairline p-12 text-center tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Add frames to populate the contact sheet.</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, frame := range frames {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<figure class=\"noir-sprocket noir-figure\" data-frame-number=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 string
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(frame.Frame)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 14, Col: 79}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><span class=\"noir-frame-number tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(frame.Frame)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 16, Col: 21}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if frame.Image != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(frame.Image)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 20, Col: 26}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(frame.Label)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 21, Col: 26}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" loading=\"lazy\" class=\"w-full h-auto block mt-2\"> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"aspect-square hairline\" style=\"background-color: hsl(var(--muted));\"></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if frame.Label != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<figcaption class=\"tracked-mono mt-2\" style=\"color: hsl(var(--mutedForeground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(frame.Label)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 30, Col: 22}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</figcaption>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</figure>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NoirEmailWrapper wraps body content in a pure black 600px canvas with a
|
|
||||||
// Tenor Sans masthead and a mono caption strip above the footer.
|
|
||||||
func NoirEmailWrapper(body string, emailCtx templates.EmailContext) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = noirEmailTemplate(emailCtx, body).Render(context.Background(), &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirEmailTemplate renders the inline-styled HTML email wrapper.
|
|
||||||
templ noirEmailTemplate(emailCtx templates.EmailContext, body string) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<meta name="x-apple-disable-message-reformatting"/>
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
|
||||||
<title>{ emailCtx.SiteSettings.SiteName }</title>
|
|
||||||
</head>
|
|
||||||
<body style={ fmt.Sprintf("background-color: %s; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;", noirEmailBg(emailCtx)) }>
|
|
||||||
if emailCtx.PreviewText != "" {
|
|
||||||
<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">{ emailCtx.PreviewText }</div>
|
|
||||||
}
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style={ fmt.Sprintf("background-color: %s;", noirEmailBg(emailCtx)) }>
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding: 48px 12px;">
|
|
||||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style={ fmt.Sprintf("width: 600px; max-width: 600px; background-color: %s; border: 1px solid %s;", noirEmailCard(emailCtx), noirEmailBorder(emailCtx)) }>
|
|
||||||
<!-- Masthead -->
|
|
||||||
<tr>
|
|
||||||
<td align="left" style={ fmt.Sprintf("padding: 28px 32px; border-bottom: 1px solid %s;", noirEmailBorder(emailCtx)) }>
|
|
||||||
if emailCtx.SiteSettings.SiteName != "" {
|
|
||||||
<div style={ fmt.Sprintf("margin: 0; font-size: 18px; font-weight: 400; font-family: 'Tenor Sans', Georgia, serif; letter-spacing: 0.05em; color: %s;", noirEmailFg(emailCtx)) }>
|
|
||||||
{ emailCtx.SiteSettings.SiteName }
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Cover image (16:10) -->
|
|
||||||
if emailCtx.SiteSettings.LogoURL != "" {
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 0; line-height: 0;">
|
|
||||||
<img src={ emailCtx.SiteSettings.LogoURL } alt={ emailCtx.SiteSettings.SiteName } width="600" height="375" style="display: block; width: 600px; height: 375px; object-fit: cover; max-width: 100%;"/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
<!-- Body -->
|
|
||||||
<tr>
|
|
||||||
<td style={ fmt.Sprintf("padding: 32px; color: %s; font-size: 16px; line-height: 1.7;", noirEmailFg(emailCtx)) }>
|
|
||||||
@templ.Raw(body)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Mono caption strip -->
|
|
||||||
<tr>
|
|
||||||
<td style={ fmt.Sprintf("padding: 12px 32px; border-top: 1px solid %s; border-bottom: 1px solid %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: %s;", noirEmailBorder(emailCtx), noirEmailBorder(emailCtx), noirEmailMutedFg(emailCtx)) }>
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
|
||||||
<tr>
|
|
||||||
<td align="left">
|
|
||||||
if emailCtx.SiteSettings.SiteName != "" {
|
|
||||||
{ "© " + emailCtx.SiteSettings.SiteName }
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td align="right">
|
|
||||||
if emailCtx.UnsubscribeURL != "" {
|
|
||||||
<a href={ templ.SafeURL(emailCtx.UnsubscribeURL) } style={ fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)) }>
|
|
||||||
Unsubscribe
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Footer -->
|
|
||||||
<tr>
|
|
||||||
<td align="center" style={ fmt.Sprintf("padding: 20px 32px; color: %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;", noirEmailMutedFg(emailCtx)) }>
|
|
||||||
if emailCtx.SiteSettings.SiteURL != "" {
|
|
||||||
<a href={ templ.SafeURL(emailCtx.SiteSettings.SiteURL) } style={ fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)) }>
|
|
||||||
{ emailCtx.SiteSettings.SiteURL }
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirEmailBg returns the canvas colour (pure black per spec) with a fallback
|
|
||||||
// derived from the EmailContext.
|
|
||||||
func noirEmailBg(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Background != "" {
|
|
||||||
return emailCtx.Colors.Background
|
|
||||||
}
|
|
||||||
return "#000000"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailCard(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Card != "" {
|
|
||||||
return emailCtx.Colors.Card
|
|
||||||
}
|
|
||||||
return "#0a0a0a"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailFg(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Foreground != "" {
|
|
||||||
return emailCtx.Colors.Foreground
|
|
||||||
}
|
|
||||||
return "#f5f5f5"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailMutedFg(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.MutedForeground != "" {
|
|
||||||
return emailCtx.Colors.MutedForeground
|
|
||||||
}
|
|
||||||
return "#8c8c8c"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailBorder(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Border != "" {
|
|
||||||
return emailCtx.Colors.Border
|
|
||||||
}
|
|
||||||
return "#1f1f1f"
|
|
||||||
}
|
|
||||||
@ -1,401 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NoirEmailWrapper wraps body content in a pure black 600px canvas with a
|
|
||||||
// Tenor Sans masthead and a mono caption strip above the footer.
|
|
||||||
func NoirEmailWrapper(body string, emailCtx templates.EmailContext) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = noirEmailTemplate(emailCtx, body).Render(context.Background(), &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirEmailTemplate renders the inline-styled HTML email wrapper.
|
|
||||||
func noirEmailTemplate(emailCtx templates.EmailContext, body string) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta name=\"x-apple-disable-message-reformatting\"><meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"><title>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 string
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 28, Col: 41}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title></head><body style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background-color: %s; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;", noirEmailBg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 30, Col: 177}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if emailCtx.PreviewText != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div style=\"display: none; max-height: 0; overflow: hidden; mso-hide: all;\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.PreviewText)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 32, Col: 101}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background-color: %s;", noirEmailBg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 34, Col: 152}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><tr><td align=\"center\" style=\"padding: 48px 12px;\"><table role=\"presentation\" width=\"600\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: 600px; max-width: 600px; background-color: %s; border: 1px solid %s;", noirEmailCard(emailCtx), noirEmailBorder(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 37, Col: 237}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><!-- Masthead --><tr><td align=\"left\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 28px 32px; border-bottom: 1px solid %s;", noirEmailBorder(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 40, Col: 122}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if emailCtx.SiteSettings.SiteName != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("margin: 0; font-size: 18px; font-weight: 400; font-family: 'Tenor Sans', Georgia, serif; letter-spacing: 0.05em; color: %s;", noirEmailFg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 42, Col: 183}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 43, Col: 42}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td></tr><!-- Cover image (16:10) -->")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if emailCtx.SiteSettings.LogoURL != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr><td style=\"padding: 0; line-height: 0;\"><img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(emailCtx.SiteSettings.LogoURL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 52, Col: 49}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var11 string
|
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(emailCtx.SiteSettings.SiteName)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 52, Col: 88}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" width=\"600\" height=\"375\" style=\"display: block; width: 600px; height: 375px; object-fit: cover; max-width: 100%;\"></td></tr>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Body --><tr><td style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var12 string
|
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 32px; color: %s; font-size: 16px; line-height: 1.7;", noirEmailFg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 58, Col: 117}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(body).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td></tr><!-- Mono caption strip --><tr><td style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 string
|
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 12px 32px; border-top: 1px solid %s; border-bottom: 1px solid %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: %s;", noirEmailBorder(emailCtx), noirEmailBorder(emailCtx), noirEmailMutedFg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 64, Col: 321}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\"><tr><td align=\"left\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if emailCtx.SiteSettings.SiteName != "" {
|
|
||||||
var templ_7745c5c3_Var14 string
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs("© " + emailCtx.SiteSettings.SiteName)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 69, Col: 52}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td align=\"right\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if emailCtx.UnsubscribeURL != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(emailCtx.UnsubscribeURL))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 74, Col: 60}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var16 string
|
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 74, Col: 147}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">Unsubscribe</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td></tr></table></td></tr><!-- Footer --><tr><td align=\"center\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var17 string
|
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 20px 32px; color: %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;", noirEmailMutedFg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 85, Col: 227}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if emailCtx.SiteSettings.SiteURL != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(emailCtx.SiteSettings.SiteURL))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 87, Col: 63}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var19 string
|
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 87, Col: 150}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var20 string
|
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteURL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 88, Col: 41}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td></tr></table></td></tr></table></body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirEmailBg returns the canvas colour (pure black per spec) with a fallback
|
|
||||||
// derived from the EmailContext.
|
|
||||||
func noirEmailBg(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Background != "" {
|
|
||||||
return emailCtx.Colors.Background
|
|
||||||
}
|
|
||||||
return "#000000"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailCard(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Card != "" {
|
|
||||||
return emailCtx.Colors.Card
|
|
||||||
}
|
|
||||||
return "#0a0a0a"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailFg(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Foreground != "" {
|
|
||||||
return emailCtx.Colors.Foreground
|
|
||||||
}
|
|
||||||
return "#f5f5f5"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailMutedFg(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.MutedForeground != "" {
|
|
||||||
return emailCtx.Colors.MutedForeground
|
|
||||||
}
|
|
||||||
return "#8c8c8c"
|
|
||||||
}
|
|
||||||
|
|
||||||
func noirEmailBorder(emailCtx templates.EmailContext) string {
|
|
||||||
if emailCtx.Colors.Border != "" {
|
|
||||||
return emailCtx.Colors.Border
|
|
||||||
}
|
|
||||||
return "#1f1f1f"
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
64
embed.go
@ -1,64 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed assets/*
|
|
||||||
var assetsFS embed.FS
|
|
||||||
|
|
||||||
//go:embed schemas/*
|
|
||||||
var schemasFS embed.FS
|
|
||||||
|
|
||||||
//go:embed presets.json
|
|
||||||
var presetsData []byte
|
|
||||||
|
|
||||||
//go:embed fonts.json
|
|
||||||
var fontsData []byte
|
|
||||||
|
|
||||||
//go:embed plugin.mod
|
|
||||||
var pluginModBytes []byte
|
|
||||||
|
|
||||||
// Assets returns the embedded assets filesystem.
|
|
||||||
func Assets() fs.FS {
|
|
||||||
sub, _ := fs.Sub(assetsFS, "assets")
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schemas returns the embedded schemas filesystem.
|
|
||||||
func Schemas() fs.FS {
|
|
||||||
sub, _ := fs.Sub(schemasFS, "schemas")
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssetsHandler returns an http.Handler that serves the embedded assets.
|
|
||||||
func AssetsHandler() http.Handler {
|
|
||||||
return http.FileServer(http.FS(Assets()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThemePresets returns the embedded theme presets JSON.
|
|
||||||
func ThemePresets() []byte {
|
|
||||||
return presetsData
|
|
||||||
}
|
|
||||||
|
|
||||||
// BundledFonts returns the embedded fonts manifest JSON.
|
|
||||||
func BundledFonts() []byte {
|
|
||||||
return fontsData
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThemeCSSManifest exposes Noir's custom utility CSS (.bleed, .tracked-mono,
|
|
||||||
// .hairline, lightbox keyframes) to the host Tailwind input so the classes
|
|
||||||
// survive the content scanner.
|
|
||||||
func ThemeCSSManifest() *plugin.CSSManifest {
|
|
||||||
css, err := assetsFS.ReadFile("assets/style.css")
|
|
||||||
if err != nil {
|
|
||||||
return &plugin.CSSManifest{}
|
|
||||||
}
|
|
||||||
return &plugin.CSSManifest{
|
|
||||||
InputCSSAppend: string(css),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
fonts.json
@ -1 +1,18 @@
|
|||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"name": "Source Serif 4",
|
||||||
|
"family": "Source Serif 4",
|
||||||
|
"variants": [
|
||||||
|
{ "weight": "400", "style": "normal", "file": "fonts/source-serif-4-400.woff2" },
|
||||||
|
{ "weight": "400", "style": "italic", "file": "fonts/source-serif-4-400-italic.woff2" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IBM Plex Mono",
|
||||||
|
"family": "IBM Plex Mono",
|
||||||
|
"variants": [
|
||||||
|
{ "weight": "400", "style": "normal", "file": "fonts/ibm-plex-mono-400.woff2" },
|
||||||
|
{ "weight": "500", "style": "normal", "file": "fonts/ibm-plex-mono-500.woff2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
48
footer.go
@ -1,48 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FooterBlockMeta defines the Noir dissolved-rail footer block.
|
|
||||||
var FooterBlockMeta = blocks.BlockMeta{
|
|
||||||
Key: "footer",
|
|
||||||
Title: "Noir Footer",
|
|
||||||
Description: "Dissolved bottom rail with optional colophon and social links.",
|
|
||||||
Source: "noir",
|
|
||||||
Category: blocks.CategoryLayout,
|
|
||||||
}
|
|
||||||
|
|
||||||
// FooterBlock renders the Noir footer.
|
|
||||||
// Content shape: {"showColophon":"true","social":[{"text":"Instagram","url":"..."}, ...]}
|
|
||||||
func FooterBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
data := FooterData{
|
|
||||||
ShowColophon: getBoolish(content, "showColophon", true),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range getSlice(content, "social") {
|
|
||||||
data.Social = append(data.Social, FooterLink{
|
|
||||||
Text: getString(item, "text"),
|
|
||||||
URL: getString(item, "url"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = footerComponent(data).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// FooterData holds the parsed view-model for the Noir footer.
|
|
||||||
type FooterData struct {
|
|
||||||
ShowColophon bool
|
|
||||||
Social []FooterLink
|
|
||||||
}
|
|
||||||
|
|
||||||
// FooterLink is a single social-link entry.
|
|
||||||
type FooterLink struct {
|
|
||||||
Text string
|
|
||||||
URL string
|
|
||||||
}
|
|
||||||
33
footer.templ
@ -1,33 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// footerComponent renders the Noir footer rail.
|
|
||||||
templ footerComponent(data FooterData) {
|
|
||||||
<div data-block="noir:footer" class="hairline-t" style="padding: 1rem 1.5rem;">
|
|
||||||
<div class="max-w-6xl mx-auto flex items-center justify-between gap-4 flex-wrap">
|
|
||||||
if data.ShowColophon {
|
|
||||||
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">
|
|
||||||
Designed and printed in the darkroom.
|
|
||||||
</p>
|
|
||||||
} else {
|
|
||||||
<span></span>
|
|
||||||
}
|
|
||||||
if len(data.Social) > 0 {
|
|
||||||
<ul class="flex items-center gap-4 list-none p-0 m-0">
|
|
||||||
for _, link := range data.Social {
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(link.URL) }
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
class="tracked-mono"
|
|
||||||
style="color: hsl(var(--mutedForeground)); text-decoration: none;"
|
|
||||||
>
|
|
||||||
{ link.Text }
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
// footerComponent renders the Noir footer rail.
|
|
||||||
func footerComponent(data FooterData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div data-block=\"noir:footer\" class=\"hairline-t\" style=\"padding: 1rem 1.5rem;\"><div class=\"max-w-6xl mx-auto flex items-center justify-between gap-4 flex-wrap\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if data.ShowColophon {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<p class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Designed and printed in the darkroom.</p>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<span></span> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(data.Social) > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<ul class=\"flex items-center gap-4 list-none p-0 m-0\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, link := range data.Social {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<li><a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(link.URL))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `footer.templ`, Line: 19, Col: 38}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" rel=\"noopener noreferrer\" target=\"_blank\" class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground)); text-decoration: none;\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(link.Text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `footer.templ`, Line: 25, Col: 19}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</a></li>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</ul>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
20
go.mod
@ -1,20 +0,0 @@
|
|||||||
module git.dev.alexdunmow.com/block/themes/noir
|
|
||||||
|
|
||||||
go 1.26.4
|
|
||||||
|
|
||||||
require (
|
|
||||||
git.dev.alexdunmow.com/block/core v0.14.1
|
|
||||||
github.com/a-h/templ v0.3.1020
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
connectrpc.com/connect v1.20.0 // indirect
|
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
|
||||||
golang.org/x/text v0.36.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
)
|
|
||||||
44
go.sum
@ -1,44 +0,0 @@
|
|||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
|
||||||
git.dev.alexdunmow.com/block/core v0.14.0 h1:cO6QQQPndhxwh1zbDvDOzebwrFS8Ka3WsxD8JBJLw/Y=
|
|
||||||
git.dev.alexdunmow.com/block/core v0.14.0/go.mod h1:S0ZfpGZ9BQhhmuTUd78ailH7m2vo7QRCTIionvCVm9s=
|
|
||||||
git.dev.alexdunmow.com/block/core v0.14.1 h1:63b25yzWoqrhBd3wdDFhsJzyGNM99wYmdh4WkN35hDk=
|
|
||||||
git.dev.alexdunmow.com/block/core v0.14.1/go.mod h1:S0ZfpGZ9BQhhmuTUd78ailH7m2vo7QRCTIionvCVm9s=
|
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
75
helpers.go
@ -1,75 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "strconv"
|
|
||||||
|
|
||||||
// getString extracts a string value from content map. Returns "" if missing or wrong type.
|
|
||||||
func getString(content map[string]any, key string) string {
|
|
||||||
if v, ok := content[key].(string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInt extracts an int value from content map (handles float64 from JSON).
|
|
||||||
func getInt(content map[string]any, key string, defaultVal int) int {
|
|
||||||
if v, ok := content[key].(float64); ok {
|
|
||||||
return int(v)
|
|
||||||
}
|
|
||||||
if v, ok := content[key].(int); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
if v, ok := content[key].(string); ok {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSlice extracts a slice of maps from content. Returns nil if missing.
|
|
||||||
func getSlice(content map[string]any, key string) []map[string]any {
|
|
||||||
if v, ok := content[key].([]any); ok {
|
|
||||||
result := make([]map[string]any, 0, len(v))
|
|
||||||
for _, item := range v {
|
|
||||||
if m, ok := item.(map[string]any); ok {
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStringSlice extracts a slice of strings from content. Non-string entries are coerced via fmt.Sprintf.
|
|
||||||
// Returns nil if missing.
|
|
||||||
func getStringSlice(content map[string]any, key string) []string {
|
|
||||||
if v, ok := content[key].([]any); ok {
|
|
||||||
result := make([]string, 0, len(v))
|
|
||||||
for _, item := range v {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if v, ok := content[key].([]string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBoolish accepts "true"/"false" strings and booleans. Returns defaultVal otherwise.
|
|
||||||
func getBoolish(content map[string]any, key string, defaultVal bool) bool {
|
|
||||||
if v, ok := content[key].(bool); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
if v, ok := content[key].(string); ok {
|
|
||||||
switch v {
|
|
||||||
case "true":
|
|
||||||
return true
|
|
||||||
case "false":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ImagePairBlockMeta defines the Noir diptych block.
|
|
||||||
var ImagePairBlockMeta = blocks.BlockMeta{
|
|
||||||
Key: "image_pair",
|
|
||||||
Title: "Image Pair",
|
|
||||||
Description: "50/50 photograph diptych with a shared caption beneath both images.",
|
|
||||||
Source: "noir",
|
|
||||||
Category: blocks.CategoryContent,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImagePairBlock renders a two-photograph diptych.
|
|
||||||
// Content shape: {"left":"...","right":"...","caption":"..."}
|
|
||||||
func ImagePairBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
data := ImagePairData{
|
|
||||||
Left: blocks.ResolveMediaPath(getString(content, "left")),
|
|
||||||
Right: blocks.ResolveMediaPath(getString(content, "right")),
|
|
||||||
Caption: getString(content, "caption"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = imagePairComponent(data).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImagePairData holds the parsed view-model for the diptych.
|
|
||||||
type ImagePairData struct {
|
|
||||||
Left string
|
|
||||||
Right string
|
|
||||||
Caption string
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// imagePairComponent renders a 50/50 photograph diptych with a shared caption.
|
|
||||||
templ imagePairComponent(data ImagePairData) {
|
|
||||||
<section data-block="noir:image_pair" class="py-12">
|
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" style="grid-template-columns: 1fr 1fr;">
|
|
||||||
if data.Left != "" {
|
|
||||||
<figure class="noir-figure">
|
|
||||||
<img src={ data.Left } alt={ data.Caption } loading="lazy" class="w-full h-auto block"/>
|
|
||||||
</figure>
|
|
||||||
} else {
|
|
||||||
<div class="hairline aspect-[4/5]" style="background-color: hsl(var(--muted));"></div>
|
|
||||||
}
|
|
||||||
if data.Right != "" {
|
|
||||||
<figure class="noir-figure">
|
|
||||||
<img src={ data.Right } alt={ data.Caption } loading="lazy" class="w-full h-auto block"/>
|
|
||||||
</figure>
|
|
||||||
} else {
|
|
||||||
<div class="hairline aspect-[4/5]" style="background-color: hsl(var(--muted));"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
if data.Caption != "" {
|
|
||||||
<p class="tracked-mono mt-3 text-center" style="color: hsl(var(--mutedForeground));">
|
|
||||||
{ data.Caption }
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
// imagePairComponent renders a 50/50 photograph diptych with a shared caption.
|
|
||||||
func imagePairComponent(data ImagePairData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:image_pair\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\" style=\"grid-template-columns: 1fr 1fr;\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if data.Left != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<figure class=\"noir-figure\"><img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 string
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Left)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 10, Col: 26}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 10, Col: 47}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" loading=\"lazy\" class=\"w-full h-auto block\"></figure>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"hairline aspect-[4/5]\" style=\"background-color: hsl(var(--muted));\"></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if data.Right != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<figure class=\"noir-figure\"><img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Right)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 17, Col: 27}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 17, Col: 48}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" loading=\"lazy\" class=\"w-full h-auto block\"></figure>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"hairline aspect-[4/5]\" style=\"background-color: hsl(var(--muted));\"></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if data.Caption != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p class=\"tracked-mono mt-3 text-center\" style=\"color: hsl(var(--mutedForeground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.Caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 25, Col: 19}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LightboxGalleryBlockMeta defines the Noir lightbox gallery block.
|
|
||||||
var LightboxGalleryBlockMeta = blocks.BlockMeta{
|
|
||||||
Key: "lightbox_gallery",
|
|
||||||
Title: "Lightbox Gallery",
|
|
||||||
Description: "Grid of photographs that expand into a full-viewport lightbox overlay on click.",
|
|
||||||
Source: "noir",
|
|
||||||
Category: blocks.CategoryContent,
|
|
||||||
}
|
|
||||||
|
|
||||||
// LightboxGalleryBlock renders the gallery from content.items + content.columns.
|
|
||||||
// Content shape: {"items":[{"image":"...","caption":"..."}, ...], "columns": 2|3|4}
|
|
||||||
func LightboxGalleryBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
items := getSlice(content, "items")
|
|
||||||
cols := getInt(content, "columns", 3)
|
|
||||||
if cols != 2 && cols != 3 && cols != 4 {
|
|
||||||
cols = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []LightboxItem
|
|
||||||
for _, item := range items {
|
|
||||||
entries = append(entries, LightboxItem{
|
|
||||||
Image: blocks.ResolveMediaPath(getString(item, "image")),
|
|
||||||
Caption: getString(item, "caption"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = lightboxGalleryComponent(entries, cols).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// LightboxItem represents one photograph in the gallery.
|
|
||||||
type LightboxItem struct {
|
|
||||||
Image string
|
|
||||||
Caption string
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// lightboxGalleryComponent renders the gallery grid with lightbox triggers.
|
|
||||||
templ lightboxGalleryComponent(items []LightboxItem, cols int) {
|
|
||||||
<section data-block="noir:lightbox_gallery" class="py-12">
|
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
|
||||||
if len(items) == 0 {
|
|
||||||
<div class="hairline p-12 text-center tracked-mono" style="color: hsl(var(--mutedForeground));">
|
|
||||||
Add photographs to populate the gallery.
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class={ "grid gap-4", lightboxGridCols(cols) }>
|
|
||||||
for i, item := range items {
|
|
||||||
<figure class="noir-figure">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-noir-lightbox-trigger
|
|
||||||
data-src={ item.Image }
|
|
||||||
data-alt={ item.Caption }
|
|
||||||
data-caption={ item.Caption }
|
|
||||||
class="block w-full p-0 m-0 cursor-zoom-in bg-transparent border-0"
|
|
||||||
style="background: transparent;"
|
|
||||||
aria-label={ lightboxAriaLabel(i, item.Caption) }
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={ item.Image }
|
|
||||||
alt={ item.Caption }
|
|
||||||
loading="lazy"
|
|
||||||
class="w-full h-auto block"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
if item.Caption != "" {
|
|
||||||
<figcaption class="tracked-mono mt-2" style="color: hsl(var(--mutedForeground));">
|
|
||||||
{ item.Caption }
|
|
||||||
</figcaption>
|
|
||||||
}
|
|
||||||
</figure>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
// lightboxGridCols returns Tailwind grid-template-columns utility for the requested columns.
|
|
||||||
func lightboxGridCols(cols int) string {
|
|
||||||
switch cols {
|
|
||||||
case 2:
|
|
||||||
return "grid-cols-1 md:grid-cols-2"
|
|
||||||
case 4:
|
|
||||||
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
|
|
||||||
default: // 3
|
|
||||||
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lightboxAriaLabel builds an accessible button label for each gallery entry.
|
|
||||||
func lightboxAriaLabel(index int, caption string) string {
|
|
||||||
if caption != "" {
|
|
||||||
return "Open photograph: " + caption
|
|
||||||
}
|
|
||||||
return "Open photograph " + intToString(index+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// intToString avoids importing strconv into a templ-generated file.
|
|
||||||
func intToString(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
neg := false
|
|
||||||
if n < 0 {
|
|
||||||
neg = true
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
var b [20]byte
|
|
||||||
i := len(b)
|
|
||||||
for n > 0 {
|
|
||||||
i--
|
|
||||||
b[i] = byte('0' + n%10)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
i--
|
|
||||||
b[i] = '-'
|
|
||||||
}
|
|
||||||
return string(b[i:])
|
|
||||||
}
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
// lightboxGalleryComponent renders the gallery grid with lightbox triggers.
|
|
||||||
func lightboxGalleryComponent(items []LightboxItem, cols int) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:lightbox_gallery\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"hairline p-12 text-center tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Add photographs to populate the gallery.</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var templ_7745c5c3_Var2 = []any{"grid gap-4", lightboxGridCols(cols)}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for i, item := range items {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<figure class=\"noir-figure\"><button type=\"button\" data-noir-lightbox-trigger data-src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Image)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 18, Col: 29}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 19, Col: 31}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-caption=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 20, Col: 35}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"block w-full p-0 m-0 cursor-zoom-in bg-transparent border-0\" style=\"background: transparent;\" aria-label=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lightboxAriaLabel(i, item.Caption))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 23, Col: 55}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Image)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 26, Col: 25}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 27, Col: 27}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" loading=\"lazy\" class=\"w-full h-auto block\"></button> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if item.Caption != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<figcaption class=\"tracked-mono mt-2\" style=\"color: hsl(var(--mutedForeground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 34, Col: 23}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</figcaption>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</figure>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// lightboxGridCols returns Tailwind grid-template-columns utility for the requested columns.
|
|
||||||
func lightboxGridCols(cols int) string {
|
|
||||||
switch cols {
|
|
||||||
case 2:
|
|
||||||
return "grid-cols-1 md:grid-cols-2"
|
|
||||||
case 4:
|
|
||||||
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
|
|
||||||
default: // 3
|
|
||||||
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lightboxAriaLabel builds an accessible button label for each gallery entry.
|
|
||||||
func lightboxAriaLabel(index int, caption string) string {
|
|
||||||
if caption != "" {
|
|
||||||
return "Open photograph: " + caption
|
|
||||||
}
|
|
||||||
return "Open photograph " + intToString(index+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// intToString avoids importing strconv into a templ-generated file.
|
|
||||||
func intToString(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
neg := false
|
|
||||||
if n < 0 {
|
|
||||||
neg = true
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
var b [20]byte
|
|
||||||
i := len(b)
|
|
||||||
for n > 0 {
|
|
||||||
i--
|
|
||||||
b[i] = byte('0' + n%10)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
i--
|
|
||||||
b[i] = '-'
|
|
||||||
}
|
|
||||||
return string(b[i:])
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
363
manifest.yaml
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
# Noir theme — codeless .bnp manifest (WO-TF-022, Wave A).
|
||||||
|
# Detective / true-crime dossier. Synthesized into manifest.pb by
|
||||||
|
# `ninja plugin build` (no plugin.wasm).
|
||||||
|
theme_presets: presets.json
|
||||||
|
bundled_fonts: fonts.json
|
||||||
|
master_pages: master_pages.json
|
||||||
|
|
||||||
|
system_templates:
|
||||||
|
- key: noir
|
||||||
|
title: Noir
|
||||||
|
description: "Detective and true-crime dossier theme: case-file cards, typewriter type, redaction bars, evidence board and film grain. High-key gallery by day, darkroom black by night."
|
||||||
|
|
||||||
|
page_templates:
|
||||||
|
- system: noir
|
||||||
|
key: default
|
||||||
|
title: Default
|
||||||
|
description: "Case-file page with a typed masthead and a dissolved footer rule."
|
||||||
|
slots: [header, main, footer]
|
||||||
|
- system: noir
|
||||||
|
key: full-width
|
||||||
|
title: Full Width
|
||||||
|
description: "Edge-to-edge dossier layout for evidence boards and lightbox grids."
|
||||||
|
slots: [header, main, footer]
|
||||||
|
- system: noir
|
||||||
|
key: landing
|
||||||
|
title: Landing
|
||||||
|
description: "Spotlit case hero over film grain, a caption rule and a closing CTA."
|
||||||
|
slots: [hero, main, cta, footer]
|
||||||
|
- system: noir
|
||||||
|
key: article
|
||||||
|
title: Case File
|
||||||
|
description: "Long-form case write-up with a sticky evidence rail and typed prose."
|
||||||
|
slots: [header, main, aside, footer]
|
||||||
|
- system: noir
|
||||||
|
key: blog-index
|
||||||
|
title: Case Index
|
||||||
|
description: "Blog index framed as a case log, with featured and post-grid slots."
|
||||||
|
slots: [header, featured, main, footer]
|
||||||
|
- system: noir
|
||||||
|
key: contact
|
||||||
|
title: Tip Line
|
||||||
|
description: "Contact layout with an optional aside for hours and location."
|
||||||
|
slots: [header, main, aside, footer]
|
||||||
|
- system: noir
|
||||||
|
key: auth
|
||||||
|
title: Access
|
||||||
|
description: "Centred access shell with a typed wordmark over a darkroom ground."
|
||||||
|
slots: [main, footer]
|
||||||
|
|
||||||
|
template_overrides:
|
||||||
|
# Chrome (4)
|
||||||
|
- { template: noir, block: navbar }
|
||||||
|
- { template: noir, block: footer }
|
||||||
|
- { template: noir, block: utility-bar }
|
||||||
|
- { template: noir, block: announcement-bar }
|
||||||
|
# Marketing (12)
|
||||||
|
- { template: noir, block: hero }
|
||||||
|
- { template: noir, block: feature-grid }
|
||||||
|
- { template: noir, block: rich-section }
|
||||||
|
- { template: noir, block: stats }
|
||||||
|
- { template: noir, block: testimonial }
|
||||||
|
- { template: noir, block: logo-strip }
|
||||||
|
- { template: noir, block: pricing }
|
||||||
|
- { template: noir, block: team }
|
||||||
|
- { template: noir, block: faq }
|
||||||
|
- { template: noir, block: timeline }
|
||||||
|
- { template: noir, block: cta }
|
||||||
|
- { template: noir, block: countdown }
|
||||||
|
# Primitives (11)
|
||||||
|
- { template: noir, block: heading }
|
||||||
|
- { template: noir, block: text }
|
||||||
|
- { template: noir, block: rich-text }
|
||||||
|
- { template: noir, block: image }
|
||||||
|
- { template: noir, block: quote }
|
||||||
|
- { template: noir, block: divider }
|
||||||
|
- { template: noir, block: button }
|
||||||
|
- { template: noir, block: card }
|
||||||
|
- { template: noir, block: gallery }
|
||||||
|
- { template: noir, block: video-embed }
|
||||||
|
- { template: noir, block: audio-embed }
|
||||||
|
# Commerce / local (6)
|
||||||
|
- { template: noir, block: menu-list }
|
||||||
|
- { template: noir, block: event-list }
|
||||||
|
- { template: noir, block: contact-form }
|
||||||
|
- { template: noir, block: contact-card }
|
||||||
|
- { template: noir, block: hours-location }
|
||||||
|
- { template: noir, block: social-links }
|
||||||
|
# Blog family (12)
|
||||||
|
- { template: noir, block: blog-hero }
|
||||||
|
- { template: noir, block: featured-posts }
|
||||||
|
- { template: noir, block: related-posts }
|
||||||
|
- { template: noir, block: popular-posts }
|
||||||
|
- { template: noir, block: category-list }
|
||||||
|
- { template: noir, block: post-hero }
|
||||||
|
- { template: noir, block: post-metadata }
|
||||||
|
- { template: noir, block: author-bio }
|
||||||
|
- { template: noir, block: author-bio-hero }
|
||||||
|
- { template: noir, block: newsletter-signup }
|
||||||
|
- { template: noir, block: breadcrumbs }
|
||||||
|
- { template: noir, block: sidebar-nav }
|
||||||
|
# Auth surface (3)
|
||||||
|
- { template: noir, block: auth-form }
|
||||||
|
- { template: noir, block: password-reset-form }
|
||||||
|
- { template: noir, block: auth-status }
|
||||||
|
# System (1)
|
||||||
|
- { template: noir, block: page-suggestions }
|
||||||
|
|
||||||
|
email_wrappers:
|
||||||
|
- noir
|
||||||
|
|
||||||
|
css:
|
||||||
|
input_css_append: |
|
||||||
|
/* ============================================================
|
||||||
|
Noir Theme — Detective / true-crime dossier utility CSS.
|
||||||
|
Injected via CSSManifest.InputCSSAppend.
|
||||||
|
|
||||||
|
Colour is ALWAYS consumed via hsl(var(--token)) with kebab-case
|
||||||
|
tokens (the CMS emits --muted-foreground, --card-foreground, ...
|
||||||
|
from the camelCase preset keys). Font families ALWAYS flow through
|
||||||
|
var(--font-heading|body|mono, <fallback>) — never a hardcoded face.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Bundled faces. The host does not yet emit @font-face for
|
||||||
|
bundled_fonts (LoadedPlugin.BundledFonts is unconsumed), so the theme
|
||||||
|
declares its own faces against the served artifact paths
|
||||||
|
(/templates/noir/fonts/...). Presets set typography.fontHeading/body/mono
|
||||||
|
to template:noir:<Family> so --font-heading/--font-body/--font-mono
|
||||||
|
resolve to these families. (gotham@1.2.0 precedent.) */
|
||||||
|
@font-face { font-family: 'Source Serif 4'; src: url('/templates/noir/fonts/source-serif-4-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Source Serif 4'; src: url('/templates/noir/fonts/source-serif-4-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
|
||||||
|
@font-face { font-family: 'IBM Plex Mono'; src: url('/templates/noir/fonts/ibm-plex-mono-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'IBM Plex Mono'; src: url('/templates/noir/fonts/ibm-plex-mono-500.woff2') format('woff2'); font-weight: 500; font-style: normal; font-display: swap; }
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* --- Typography slots -------------------------------------- */
|
||||||
|
/* Fallback stacks name the ideal typewriter faces first
|
||||||
|
(Special Elite / Courier Prime, admin-assignable via Google
|
||||||
|
Fonts) then the shipped OFL defaults (IBM Plex Mono, Source
|
||||||
|
Serif 4). */
|
||||||
|
.noir-heading {
|
||||||
|
font-family: var(--font-heading, "Special Elite", "IBM Plex Mono", "Courier New", monospace);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.noir-body {
|
||||||
|
font-family: var(--font-body, "Source Serif 4", Georgia, "Times New Roman", serif);
|
||||||
|
}
|
||||||
|
.noir-mono {
|
||||||
|
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .noir-label — typed evidence label / eyebrow. */
|
||||||
|
.noir-label {
|
||||||
|
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Hairline rules ---------------------------------------- */
|
||||||
|
.noir-rule { border: none; border-top: 1px solid hsl(var(--border)); margin: 0; }
|
||||||
|
.noir-rule-b { border-bottom: 1px solid hsl(var(--border)); }
|
||||||
|
.noir-hairline { border: 1px solid hsl(var(--border)); }
|
||||||
|
|
||||||
|
/* --- .noir-file — manila case-file card -------------------- */
|
||||||
|
/* Square-cornered paper panel with a hairline border and a top
|
||||||
|
file-tab drawn as a border accent. */
|
||||||
|
.noir-file {
|
||||||
|
position: relative;
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
color: hsl(var(--card-foreground));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-top: 2px solid hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .noir-tab — folder-tab caption bar sitting above a file. */
|
||||||
|
.noir-tab {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-stamp — rotated case-status stamp --------------- */
|
||||||
|
.noir-stamp {
|
||||||
|
display: inline-block;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transform: rotate(-7deg);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-redact — redaction bar over text ---------------- */
|
||||||
|
/* Solid ink bar; reveals on hover/focus. Reduced-motion safe:
|
||||||
|
the reveal is a colour swap with no transform. */
|
||||||
|
.noir-redact {
|
||||||
|
background-color: hsl(var(--foreground));
|
||||||
|
color: transparent;
|
||||||
|
border-radius: 1px;
|
||||||
|
padding: 0 0.15em;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.noir-redact:hover,
|
||||||
|
.noir-redact:focus-visible {
|
||||||
|
background-color: transparent;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-pin — evidence-board push pin ------------------- */
|
||||||
|
.noir-pin { position: relative; }
|
||||||
|
.noir-pin::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
box-shadow: 0 1px 2px hsl(var(--foreground) / 0.5);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-tape — masking-tape corner strip ---------------- */
|
||||||
|
.noir-tape { position: relative; }
|
||||||
|
.noir-tape::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(-3deg);
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
background: hsl(var(--muted-foreground) / 0.28);
|
||||||
|
border-left: 1px dashed hsl(var(--muted-foreground) / 0.4);
|
||||||
|
border-right: 1px dashed hsl(var(--muted-foreground) / 0.4);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-grain — 3.5% film-grain overlay ----------------- */
|
||||||
|
.noir-grain { position: relative; isolation: isolate; }
|
||||||
|
.noir-grain::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.035;
|
||||||
|
background-image:
|
||||||
|
repeating-radial-gradient(circle at 19% 27%, hsl(var(--foreground)) 0 1px, transparent 1px 3px),
|
||||||
|
repeating-radial-gradient(circle at 71% 63%, hsl(var(--foreground)) 0 1px, transparent 1px 3px);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-spotlight — darkroom vignette ------------------- */
|
||||||
|
.noir-spotlight { position: relative; }
|
||||||
|
.noir-spotlight::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(120% 90% at 50% 35%, transparent 40%, hsl(var(--background) / 0.85) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-btn — square outline button --------------------- */
|
||||||
|
.noir-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: transparent;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border: 1px solid hsl(var(--foreground));
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.noir-btn:hover,
|
||||||
|
.noir-btn:focus-visible {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
.noir-btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* .noir-btn-solid — filled evidence-accent variant. */
|
||||||
|
.noir-btn-solid {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
.noir-btn-solid:hover,
|
||||||
|
.noir-btn-solid:focus-visible {
|
||||||
|
background-color: transparent;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- .noir-link — evidence-accent underline link ----------- */
|
||||||
|
.noir-link {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid hsl(var(--primary));
|
||||||
|
transition: color 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.noir-link:hover,
|
||||||
|
.noir-link:focus-visible {
|
||||||
|
color: hsl(var(--accent));
|
||||||
|
border-bottom-color: hsl(var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scroll reveal (MID motion, progressive enhancement) --- */
|
||||||
|
/* No-JS / no class: content is fully visible. The page template
|
||||||
|
adds `.noir-js` to <html> only when JS runs. */
|
||||||
|
.noir-js .noir-reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
.noir-js .noir-reveal.noir-in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.noir-js .noir-reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.noir-redact { transition: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty-media graceful state — the demo seed ships no images, so any
|
||||||
|
media frame that renders an empty <img> must not balloon into a grey
|
||||||
|
box or show broken-image chrome. Images never exceed their column. */
|
||||||
|
.noir-file img, .bn-card img, .bn-gallery img, figure img { max-width: 100%; height: auto; }
|
||||||
|
img[src=""], img:not([src]) { display: none; }
|
||||||
|
|
||||||
|
/* Overflow safety — long typed/mono labels wrap rather than push the
|
||||||
|
page wider than a phone viewport (uppercase + wide tracking is the risk). */
|
||||||
|
.noir-label, .noir-mono, .noir-heading { overflow-wrap: break-word; }
|
||||||
|
/* Never let a block force a horizontal scrollbar on small screens. */
|
||||||
|
[data-block-override], [data-block] { max-width: 100%; }
|
||||||
26
master_pages.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "noir:default-master",
|
||||||
|
"title": "Noir Default Master",
|
||||||
|
"page_templates": ["default", "article", "blog-index", "contact"],
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "navbar", "title": "Case-File Masthead", "content": { "menuName": "main", "logoType": "text", "logoText": { "text": "The Cold File" } }, "slot": "header", "sort_order": 0 },
|
||||||
|
{ "block_key": "divider", "title": "Header Rule", "content": { "variant": "line", "weight": "thin", "width": "full" }, "slot": "header", "sort_order": 1 },
|
||||||
|
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main", "placeholder": "Page content" }, "slot": "main", "sort_order": 0 },
|
||||||
|
{ "block_key": "footer", "title": "Site Footer", "content": { "layout": "columns", "companyName": "The Cold File", "tagline": "Cases that were never closed." }, "slot": "footer", "sort_order": 0 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "noir:case-master",
|
||||||
|
"title": "Noir Case Master",
|
||||||
|
"page_templates": ["landing", "full-width"],
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "navbar", "title": "Case-File Masthead", "content": { "menuName": "main", "logoType": "text", "logoText": { "text": "The Cold File" }, "variant": "transparent-over-hero" }, "slot": "hero", "sort_order": 0 },
|
||||||
|
{ "block_key": "slot", "title": "Hero Content", "content": { "slotName": "hero", "placeholder": "Hero" }, "slot": "hero", "sort_order": 1 },
|
||||||
|
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main" }, "slot": "main", "sort_order": 0 },
|
||||||
|
{ "block_key": "divider", "title": "CTA Rule", "content": { "variant": "line", "weight": "thick", "width": "full" }, "slot": "cta", "sort_order": 0 },
|
||||||
|
{ "block_key": "slot", "title": "CTA Content", "content": { "slotName": "cta", "placeholder": "Closing call to action" }, "slot": "cta", "sort_order": 1 },
|
||||||
|
{ "block_key": "footer", "title": "Site Footer", "content": { "layout": "columns", "companyName": "The Cold File", "tagline": "Cases that were never closed." }, "slot": "footer", "sort_order": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
99
overrides.go
@ -1,99 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NoirHeadingBlock overrides the built-in heading.
|
|
||||||
// Renders with the display-serif family, no underline, generous tracking.
|
|
||||||
func NoirHeadingBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
text := getString(content, "text")
|
|
||||||
textClass := getString(content, "textClass")
|
|
||||||
level := parseHeadingLevel(content)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = noirHeadingComponent(level, text, textClass).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseHeadingLevel parses the heading level from content (1-6, default 2).
|
|
||||||
func parseHeadingLevel(content map[string]any) int {
|
|
||||||
if level, ok := content["level"].(float64); ok {
|
|
||||||
l := int(level)
|
|
||||||
if l >= 1 && l <= 6 {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if level, ok := content["level"].(int); ok {
|
|
||||||
if level >= 1 && level <= 6 {
|
|
||||||
return level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if level, ok := content["level"].(string); ok {
|
|
||||||
if l, err := strconv.Atoi(level); err == nil && l >= 1 && l <= 6 {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirTextBlock overrides the built-in text block.
|
|
||||||
// Renders with the humanist sans family and generous leading.
|
|
||||||
func NoirTextBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
text := getString(content, "text")
|
|
||||||
class := getString(content, "class")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = noirTextComponent(text, class).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirImageBlock overrides the built-in image block.
|
|
||||||
// Always full-bleed inside its container with a mono caption beneath.
|
|
||||||
func NoirImageBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
src := blocks.ResolveMediaPath(getString(content, "src"))
|
|
||||||
alt := getString(content, "alt")
|
|
||||||
caption := getString(content, "caption")
|
|
||||||
if caption == "" {
|
|
||||||
caption = getString(content, "title")
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = noirImageComponent(src, alt, caption).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirButtonBlock overrides the built-in button.
|
|
||||||
// Renders as a hairline 1px outline, no fill, hover inverts.
|
|
||||||
func NoirButtonBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
text := getString(content, "text")
|
|
||||||
if text == "" {
|
|
||||||
text = getString(content, "label")
|
|
||||||
}
|
|
||||||
href := getString(content, "url")
|
|
||||||
if href == "" {
|
|
||||||
href = getString(content, "href")
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = noirButtonComponent(text, href).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirCardBlock overrides the built-in card.
|
|
||||||
// Transparent background, hairline border only.
|
|
||||||
func NoirCardBlock(ctx context.Context, content map[string]any) string {
|
|
||||||
title := getString(content, "title")
|
|
||||||
body := getString(content, "body")
|
|
||||||
if body == "" {
|
|
||||||
body = getString(content, "text")
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = noirCardComponent(title, body).Render(ctx, &buf)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// noirHeadingComponent renders a heading using the display-serif font slot.
|
|
||||||
templ noirHeadingComponent(level int, text, textClass string) {
|
|
||||||
switch level {
|
|
||||||
case 1:
|
|
||||||
<h1 class={ "noir-display", textClass } style="font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; letter-spacing: 0.005em; color: hsl(var(--foreground));">
|
|
||||||
{ text }
|
|
||||||
</h1>
|
|
||||||
case 2:
|
|
||||||
<h2 class={ "noir-display", textClass } style="font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));">
|
|
||||||
{ text }
|
|
||||||
</h2>
|
|
||||||
case 3:
|
|
||||||
<h3 class={ "noir-display", textClass } style="font-size: clamp(1.5rem, 3vw, 2.25rem); line-height: 1.2; color: hsl(var(--foreground));">
|
|
||||||
{ text }
|
|
||||||
</h3>
|
|
||||||
case 4:
|
|
||||||
<h4 class={ "noir-display", textClass } style="font-size: 1.5rem; color: hsl(var(--foreground));">
|
|
||||||
{ text }
|
|
||||||
</h4>
|
|
||||||
case 5:
|
|
||||||
<h5 class={ "noir-display", textClass } style="font-size: 1.25rem; color: hsl(var(--foreground));">
|
|
||||||
{ text }
|
|
||||||
</h5>
|
|
||||||
case 6:
|
|
||||||
<h6 class={ "noir-display", textClass } style="font-size: 1.125rem; color: hsl(var(--foreground));">
|
|
||||||
{ text }
|
|
||||||
</h6>
|
|
||||||
default:
|
|
||||||
<h2 class={ "noir-display", textClass } style="font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));">
|
|
||||||
{ text }
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirTextComponent renders text with the humanist sans family and generous leading.
|
|
||||||
// The body family resolves through --font-body; the surrounding .noir-page already
|
|
||||||
// supplies the fallback stack from style.css, so we only need spacing here.
|
|
||||||
templ noirTextComponent(text, class string) {
|
|
||||||
<div class={ "noir-text", class } style="line-height: 1.7; color: hsl(var(--foreground)); max-width: 65ch;">
|
|
||||||
@templ.Raw(text)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirImageComponent renders an image full-bleed within its container with a mono caption.
|
|
||||||
templ noirImageComponent(src, alt, caption string) {
|
|
||||||
<figure class="noir-figure bleed">
|
|
||||||
if src != "" {
|
|
||||||
<img src={ src } alt={ alt } loading="lazy" class="w-full h-auto block"/>
|
|
||||||
}
|
|
||||||
if caption != "" {
|
|
||||||
<figcaption class="tracked-mono mt-2 px-6" style="color: hsl(var(--mutedForeground));">
|
|
||||||
{ caption }
|
|
||||||
</figcaption>
|
|
||||||
}
|
|
||||||
</figure>
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirButtonComponent renders the hairline outline button.
|
|
||||||
templ noirButtonComponent(text, href string) {
|
|
||||||
if href != "" {
|
|
||||||
<a href={ templ.SafeURL(href) } class="noir-btn">
|
|
||||||
{ text }
|
|
||||||
</a>
|
|
||||||
} else {
|
|
||||||
<button type="button" class="noir-btn">
|
|
||||||
{ text }
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirCardComponent renders the transparent hairline-bordered card.
|
|
||||||
templ noirCardComponent(title, body string) {
|
|
||||||
<div class="noir-card" style="padding: 1.5rem;">
|
|
||||||
if title != "" {
|
|
||||||
<h3 class="noir-display" style="font-size: 1.5rem; margin-bottom: 0.75rem; color: hsl(var(--foreground));">
|
|
||||||
{ title }
|
|
||||||
</h3>
|
|
||||||
}
|
|
||||||
if body != "" {
|
|
||||||
<div style="color: hsl(var(--foreground)); line-height: 1.6;">
|
|
||||||
@templ.Raw(body)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@ -1,577 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
// noirHeadingComponent renders a heading using the display-serif font slot.
|
|
||||||
func noirHeadingComponent(level int, text, textClass string) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
switch level {
|
|
||||||
case 1:
|
|
||||||
var templ_7745c5c3_Var2 = []any{"noir-display", textClass}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" style=\"font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; letter-spacing: 0.005em; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 8, Col: 10}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h1>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
case 2:
|
|
||||||
var templ_7745c5c3_Var5 = []any{"noir-display", textClass}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2 class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 12, Col: 10}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h2>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
case 3:
|
|
||||||
var templ_7745c5c3_Var8 = []any{"noir-display", textClass}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h3 class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" style=\"font-size: clamp(1.5rem, 3vw, 2.25rem); line-height: 1.2; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 16, Col: 10}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h3>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
case 4:
|
|
||||||
var templ_7745c5c3_Var11 = []any{"noir-display", textClass}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<h4 class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var12 string
|
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" style=\"font-size: 1.5rem; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 string
|
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 20, Col: 10}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h4>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
case 5:
|
|
||||||
var templ_7745c5c3_Var14 = []any{"noir-display", textClass}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h5 class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" style=\"font-size: 1.25rem; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var16 string
|
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 24, Col: 10}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h5>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
case 6:
|
|
||||||
var templ_7745c5c3_Var17 = []any{"noir-display", textClass}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<h6 class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var17).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" style=\"font-size: 1.125rem; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var19 string
|
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 28, Col: 10}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h6>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
var templ_7745c5c3_Var20 = []any{"noir-display", textClass}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<h2 class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" style=\"font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var22 string
|
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 32, Col: 10}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h2>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirTextComponent renders text with the humanist sans family and generous leading.
|
|
||||||
// The body family resolves through --font-body; the surrounding .noir-page already
|
|
||||||
// supplies the fallback stack from style.css, so we only need spacing here.
|
|
||||||
func noirTextComponent(text, class string) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var23 == nil {
|
|
||||||
templ_7745c5c3_Var23 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
var templ_7745c5c3_Var24 = []any{"noir-text", class}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var25 string
|
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" style=\"line-height: 1.7; color: hsl(var(--foreground)); max-width: 65ch;\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(text).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirImageComponent renders an image full-bleed within its container with a mono caption.
|
|
||||||
func noirImageComponent(src, alt, caption string) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var26 == nil {
|
|
||||||
templ_7745c5c3_Var26 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<figure class=\"noir-figure bleed\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if src != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var27 string
|
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(src)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 50, Col: 17}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var28 string
|
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(alt)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 50, Col: 29}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" loading=\"lazy\" class=\"w-full h-auto block\"> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if caption != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<figcaption class=\"tracked-mono mt-2 px-6\" style=\"color: hsl(var(--mutedForeground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var29 string
|
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(caption)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 54, Col: 13}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</figcaption>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</figure>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirButtonComponent renders the hairline outline button.
|
|
||||||
func noirButtonComponent(text, href string) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var30 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var30 == nil {
|
|
||||||
templ_7745c5c3_Var30 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
if href != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var31 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 63, Col: 31}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"noir-btn\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var32 string
|
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 64, Col: 9}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button type=\"button\" class=\"noir-btn\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var33 string
|
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(text)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 68, Col: 9}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</button>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirCardComponent renders the transparent hairline-bordered card.
|
|
||||||
func noirCardComponent(title, body string) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var34 == nil {
|
|
||||||
templ_7745c5c3_Var34 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"noir-card\" style=\"padding: 1.5rem;\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if title != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<h3 class=\"noir-display\" style=\"font-size: 1.5rem; margin-bottom: 0.75rem; color: hsl(var(--foreground));\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var35 string
|
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 78, Col: 11}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h3>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if body != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div style=\"color: hsl(var(--foreground)); line-height: 1.6;\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(body).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
@ -2,11 +2,8 @@
|
|||||||
name = "noir"
|
name = "noir"
|
||||||
display_name = "Noir"
|
display_name = "Noir"
|
||||||
scope = "@themes"
|
scope = "@themes"
|
||||||
version = "0.1.1"
|
version = "0.4.0"
|
||||||
description = "Silver-on-black photography theme with full-bleed imagery, micro mono labels, lightbox galleries and contact-sheet layouts."
|
description = "Detective and true-crime dossier theme: case-file cards, typewriter type, redaction bars, evidence board and film grain. High-key gallery by day, darkroom black by night."
|
||||||
kind = "theme"
|
kind = "theme"
|
||||||
categories = ["templates"]
|
categories = ["templates"]
|
||||||
tags = ["noir", "monochrome", "photography", "portfolio", "gallery", "filmmaker", "fashion", "darkroom"]
|
tags = ["noir", "detective", "true-crime", "dossier", "typewriter", "monochrome", "editorial", "dark"]
|
||||||
|
|
||||||
[compatibility]
|
|
||||||
block_core = ">=0.11.0 <0.12.0"
|
|
||||||
|
|||||||
349
presets.json
@ -1,39 +1,245 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "pure-noir",
|
"id": "cold-case",
|
||||||
"name": "Pure Noir",
|
"name": "Cold Case",
|
||||||
"description": "Pure black ground, silver mids, one white reserved for captions and highlight edges.",
|
"description": "The default. High-key gallery by day, ink-black type, evidence red. Darkroom black by night lit by an amber safelight.",
|
||||||
"theme": {
|
"theme": {
|
||||||
"mode": "dark",
|
"mode": "both",
|
||||||
|
"typography": {
|
||||||
|
"fontHeading": "template:noir:IBM Plex Mono",
|
||||||
|
"fontBody": "template:noir:Source Serif 4",
|
||||||
|
"fontMono": "template:noir:IBM Plex Mono",
|
||||||
|
"fontSizeBase": "17",
|
||||||
|
"lineHeightBase": "1.7"
|
||||||
|
},
|
||||||
|
"lightColors": {
|
||||||
|
"background": "40 14% 96%",
|
||||||
|
"foreground": "24 12% 10%",
|
||||||
|
"card": "40 18% 99%",
|
||||||
|
"cardForeground": "24 12% 10%",
|
||||||
|
"popover": "40 18% 99%",
|
||||||
|
"popoverForeground": "24 12% 10%",
|
||||||
|
"primary": "0 68% 42%",
|
||||||
|
"primaryForeground": "40 20% 97%",
|
||||||
|
"secondary": "40 8% 90%",
|
||||||
|
"secondaryForeground": "24 12% 14%",
|
||||||
|
"muted": "40 8% 91%",
|
||||||
|
"mutedForeground": "28 9% 30%",
|
||||||
|
"accent": "0 60% 48%",
|
||||||
|
"accentForeground": "40 20% 97%",
|
||||||
|
"destructive": "0 74% 40%",
|
||||||
|
"destructiveForeground": "40 20% 97%",
|
||||||
|
"border": "30 8% 80%",
|
||||||
|
"input": "30 8% 84%",
|
||||||
|
"ring": "0 68% 42%"
|
||||||
|
},
|
||||||
"darkColors": {
|
"darkColors": {
|
||||||
"background": "0 0% 0%",
|
"background": "30 9% 7%",
|
||||||
"foreground": "0 0% 96%",
|
"foreground": "40 20% 88%",
|
||||||
"card": "0 0% 4%",
|
"card": "30 9% 10%",
|
||||||
"cardForeground": "0 0% 96%",
|
"cardForeground": "40 20% 88%",
|
||||||
"popover": "0 0% 6%",
|
"popover": "30 9% 10%",
|
||||||
"popoverForeground": "0 0% 96%",
|
"popoverForeground": "40 20% 88%",
|
||||||
"primary": "0 0% 100%",
|
"primary": "36 82% 56%",
|
||||||
"primaryForeground": "0 0% 0%",
|
"primaryForeground": "30 22% 8%",
|
||||||
"secondary": "0 0% 10%",
|
"secondary": "30 8% 15%",
|
||||||
"secondaryForeground": "0 0% 80%",
|
"secondaryForeground": "40 16% 85%",
|
||||||
"muted": "0 0% 8%",
|
"muted": "30 8% 13%",
|
||||||
"mutedForeground": "0 0% 55%",
|
"mutedForeground": "40 10% 60%",
|
||||||
"accent": "0 0% 75%",
|
"accent": "32 78% 50%",
|
||||||
"accentForeground": "0 0% 0%",
|
"accentForeground": "30 22% 8%",
|
||||||
"destructive": "0 70% 45%",
|
"destructive": "0 70% 52%",
|
||||||
"destructiveForeground": "0 0% 98%",
|
"destructiveForeground": "40 20% 92%",
|
||||||
"border": "0 0% 14%",
|
"border": "30 8% 20%",
|
||||||
"input": "0 0% 12%",
|
"input": "30 8% 18%",
|
||||||
"ring": "0 0% 70%"
|
"ring": "36 82% 56%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "silver-print",
|
"id": "evidence-red",
|
||||||
"name": "Silver Print",
|
"name": "Evidence Red",
|
||||||
"description": "Inverted gallery-wall preset: bone white, charcoal type.",
|
"description": "Red-forward in both modes. The colour of the string on the board and the line under the name.",
|
||||||
"theme": {
|
"theme": {
|
||||||
"mode": "light",
|
"mode": "both",
|
||||||
|
"typography": {
|
||||||
|
"fontHeading": "template:noir:IBM Plex Mono",
|
||||||
|
"fontBody": "template:noir:Source Serif 4",
|
||||||
|
"fontMono": "template:noir:IBM Plex Mono",
|
||||||
|
"fontSizeBase": "17",
|
||||||
|
"lineHeightBase": "1.7"
|
||||||
|
},
|
||||||
|
"lightColors": {
|
||||||
|
"background": "20 10% 97%",
|
||||||
|
"foreground": "0 8% 8%",
|
||||||
|
"card": "20 12% 100%",
|
||||||
|
"cardForeground": "0 8% 8%",
|
||||||
|
"popover": "20 12% 100%",
|
||||||
|
"popoverForeground": "0 8% 8%",
|
||||||
|
"primary": "358 74% 44%",
|
||||||
|
"primaryForeground": "20 12% 98%",
|
||||||
|
"secondary": "20 8% 90%",
|
||||||
|
"secondaryForeground": "0 8% 12%",
|
||||||
|
"muted": "20 8% 92%",
|
||||||
|
"mutedForeground": "12 9% 29%",
|
||||||
|
"accent": "8 68% 50%",
|
||||||
|
"accentForeground": "20 12% 98%",
|
||||||
|
"destructive": "358 78% 42%",
|
||||||
|
"destructiveForeground": "20 12% 98%",
|
||||||
|
"border": "12 8% 82%",
|
||||||
|
"input": "12 8% 86%",
|
||||||
|
"ring": "358 74% 44%"
|
||||||
|
},
|
||||||
|
"darkColors": {
|
||||||
|
"background": "0 12% 6%",
|
||||||
|
"foreground": "10 12% 90%",
|
||||||
|
"card": "0 12% 9%",
|
||||||
|
"cardForeground": "10 12% 90%",
|
||||||
|
"popover": "0 12% 9%",
|
||||||
|
"popoverForeground": "10 12% 90%",
|
||||||
|
"primary": "2 78% 56%",
|
||||||
|
"primaryForeground": "0 15% 8%",
|
||||||
|
"secondary": "0 10% 14%",
|
||||||
|
"secondaryForeground": "10 12% 86%",
|
||||||
|
"muted": "0 10% 12%",
|
||||||
|
"mutedForeground": "8 10% 60%",
|
||||||
|
"accent": "6 74% 52%",
|
||||||
|
"accentForeground": "0 15% 8%",
|
||||||
|
"destructive": "2 80% 54%",
|
||||||
|
"destructiveForeground": "10 12% 92%",
|
||||||
|
"border": "0 10% 19%",
|
||||||
|
"input": "0 10% 16%",
|
||||||
|
"ring": "2 78% 56%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "safelight",
|
||||||
|
"name": "Safelight",
|
||||||
|
"description": "The darkroom under amber safelight. Warm bone paper by day, deep umber and amber glow by night.",
|
||||||
|
"theme": {
|
||||||
|
"mode": "both",
|
||||||
|
"typography": {
|
||||||
|
"fontHeading": "template:noir:IBM Plex Mono",
|
||||||
|
"fontBody": "template:noir:Source Serif 4",
|
||||||
|
"fontMono": "template:noir:IBM Plex Mono",
|
||||||
|
"fontSizeBase": "17",
|
||||||
|
"lineHeightBase": "1.7"
|
||||||
|
},
|
||||||
|
"lightColors": {
|
||||||
|
"background": "38 30% 95%",
|
||||||
|
"foreground": "30 18% 12%",
|
||||||
|
"card": "38 34% 98%",
|
||||||
|
"cardForeground": "30 18% 12%",
|
||||||
|
"popover": "38 34% 98%",
|
||||||
|
"popoverForeground": "30 18% 12%",
|
||||||
|
"primary": "32 74% 44%",
|
||||||
|
"primaryForeground": "38 30% 97%",
|
||||||
|
"secondary": "38 16% 88%",
|
||||||
|
"secondaryForeground": "30 18% 14%",
|
||||||
|
"muted": "38 14% 90%",
|
||||||
|
"mutedForeground": "32 14% 30%",
|
||||||
|
"accent": "26 70% 48%",
|
||||||
|
"accentForeground": "38 30% 97%",
|
||||||
|
"destructive": "0 70% 44%",
|
||||||
|
"destructiveForeground": "38 30% 97%",
|
||||||
|
"border": "34 16% 80%",
|
||||||
|
"input": "34 14% 84%",
|
||||||
|
"ring": "32 74% 44%"
|
||||||
|
},
|
||||||
|
"darkColors": {
|
||||||
|
"background": "28 22% 6%",
|
||||||
|
"foreground": "38 28% 88%",
|
||||||
|
"card": "28 20% 9%",
|
||||||
|
"cardForeground": "38 28% 88%",
|
||||||
|
"popover": "28 20% 9%",
|
||||||
|
"popoverForeground": "38 28% 88%",
|
||||||
|
"primary": "34 84% 56%",
|
||||||
|
"primaryForeground": "28 24% 7%",
|
||||||
|
"secondary": "28 18% 14%",
|
||||||
|
"secondaryForeground": "38 22% 84%",
|
||||||
|
"muted": "28 16% 12%",
|
||||||
|
"mutedForeground": "36 16% 60%",
|
||||||
|
"accent": "28 76% 50%",
|
||||||
|
"accentForeground": "28 24% 7%",
|
||||||
|
"destructive": "0 72% 52%",
|
||||||
|
"destructiveForeground": "38 28% 92%",
|
||||||
|
"border": "28 18% 19%",
|
||||||
|
"input": "28 16% 16%",
|
||||||
|
"ring": "34 84% 56%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "interrogation",
|
||||||
|
"name": "Interrogation",
|
||||||
|
"description": "Steel and cold light. Grey-blue neutral by day, slate-black room lit by a single steel lamp by night.",
|
||||||
|
"theme": {
|
||||||
|
"mode": "both",
|
||||||
|
"typography": {
|
||||||
|
"fontHeading": "template:noir:IBM Plex Mono",
|
||||||
|
"fontBody": "template:noir:Source Serif 4",
|
||||||
|
"fontMono": "template:noir:IBM Plex Mono",
|
||||||
|
"fontSizeBase": "17",
|
||||||
|
"lineHeightBase": "1.7"
|
||||||
|
},
|
||||||
|
"lightColors": {
|
||||||
|
"background": "210 16% 95%",
|
||||||
|
"foreground": "215 22% 12%",
|
||||||
|
"card": "210 18% 99%",
|
||||||
|
"cardForeground": "215 22% 12%",
|
||||||
|
"popover": "210 18% 99%",
|
||||||
|
"popoverForeground": "215 22% 12%",
|
||||||
|
"primary": "205 58% 40%",
|
||||||
|
"primaryForeground": "210 20% 97%",
|
||||||
|
"secondary": "210 14% 89%",
|
||||||
|
"secondaryForeground": "215 22% 14%",
|
||||||
|
"muted": "210 12% 91%",
|
||||||
|
"mutedForeground": "214 13% 30%",
|
||||||
|
"accent": "8 62% 48%",
|
||||||
|
"accentForeground": "210 20% 97%",
|
||||||
|
"destructive": "0 72% 44%",
|
||||||
|
"destructiveForeground": "210 20% 97%",
|
||||||
|
"border": "212 14% 80%",
|
||||||
|
"input": "212 12% 84%",
|
||||||
|
"ring": "205 58% 40%"
|
||||||
|
},
|
||||||
|
"darkColors": {
|
||||||
|
"background": "215 24% 7%",
|
||||||
|
"foreground": "210 18% 88%",
|
||||||
|
"card": "215 22% 10%",
|
||||||
|
"cardForeground": "210 18% 88%",
|
||||||
|
"popover": "215 22% 10%",
|
||||||
|
"popoverForeground": "210 18% 88%",
|
||||||
|
"primary": "200 70% 56%",
|
||||||
|
"primaryForeground": "215 26% 7%",
|
||||||
|
"secondary": "215 18% 15%",
|
||||||
|
"secondaryForeground": "210 16% 85%",
|
||||||
|
"muted": "215 16% 13%",
|
||||||
|
"mutedForeground": "212 14% 60%",
|
||||||
|
"accent": "6 72% 54%",
|
||||||
|
"accentForeground": "215 26% 7%",
|
||||||
|
"destructive": "0 70% 52%",
|
||||||
|
"destructiveForeground": "210 18% 92%",
|
||||||
|
"border": "215 18% 20%",
|
||||||
|
"input": "215 16% 17%",
|
||||||
|
"ring": "200 70% 56%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redaction",
|
||||||
|
"name": "Redaction",
|
||||||
|
"description": "Pure document. Stark black-on-white by day, near-monochrome by night, with the faintest red where the bar lifts.",
|
||||||
|
"theme": {
|
||||||
|
"mode": "both",
|
||||||
|
"typography": {
|
||||||
|
"fontHeading": "template:noir:IBM Plex Mono",
|
||||||
|
"fontBody": "template:noir:Source Serif 4",
|
||||||
|
"fontMono": "template:noir:IBM Plex Mono",
|
||||||
|
"fontSizeBase": "17",
|
||||||
|
"lineHeightBase": "1.7"
|
||||||
|
},
|
||||||
"lightColors": {
|
"lightColors": {
|
||||||
"background": "0 0% 98%",
|
"background": "0 0% 98%",
|
||||||
"foreground": "0 0% 8%",
|
"foreground": "0 0% 8%",
|
||||||
@ -41,69 +247,40 @@
|
|||||||
"cardForeground": "0 0% 8%",
|
"cardForeground": "0 0% 8%",
|
||||||
"popover": "0 0% 100%",
|
"popover": "0 0% 100%",
|
||||||
"popoverForeground": "0 0% 8%",
|
"popoverForeground": "0 0% 8%",
|
||||||
"primary": "0 0% 10%",
|
"primary": "0 62% 42%",
|
||||||
"primaryForeground": "0 0% 98%",
|
"primaryForeground": "0 0% 98%",
|
||||||
"secondary": "0 0% 94%",
|
"secondary": "0 0% 92%",
|
||||||
"secondaryForeground": "0 0% 12%",
|
"secondaryForeground": "0 0% 12%",
|
||||||
"muted": "0 0% 96%",
|
"muted": "0 0% 93%",
|
||||||
"mutedForeground": "0 0% 42%",
|
"mutedForeground": "0 0% 28%",
|
||||||
"accent": "0 0% 65%",
|
"accent": "0 0% 12%",
|
||||||
"accentForeground": "0 0% 100%",
|
"accentForeground": "0 0% 98%",
|
||||||
"destructive": "0 75% 50%",
|
"destructive": "0 70% 42%",
|
||||||
"destructiveForeground": "0 0% 100%",
|
"destructiveForeground": "0 0% 98%",
|
||||||
"border": "0 0% 88%",
|
"border": "0 0% 82%",
|
||||||
"input": "0 0% 90%",
|
"input": "0 0% 86%",
|
||||||
"ring": "0 0% 25%"
|
"ring": "0 62% 42%"
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "platinum",
|
|
||||||
"name": "Platinum",
|
|
||||||
"description": "Warmer silver mid-tones for fashion and editorial work, with both light and dark variants.",
|
|
||||||
"theme": {
|
|
||||||
"mode": "both",
|
|
||||||
"lightColors": {
|
|
||||||
"background": "30 6% 96%",
|
|
||||||
"foreground": "30 8% 10%",
|
|
||||||
"card": "30 6% 100%",
|
|
||||||
"cardForeground": "30 8% 10%",
|
|
||||||
"popover": "30 6% 100%",
|
|
||||||
"popoverForeground": "30 8% 10%",
|
|
||||||
"primary": "30 6% 14%",
|
|
||||||
"primaryForeground": "30 6% 96%",
|
|
||||||
"secondary": "30 4% 92%",
|
|
||||||
"secondaryForeground": "30 8% 14%",
|
|
||||||
"muted": "30 4% 94%",
|
|
||||||
"mutedForeground": "30 4% 44%",
|
|
||||||
"accent": "30 8% 60%",
|
|
||||||
"accentForeground": "30 8% 6%",
|
|
||||||
"destructive": "0 70% 48%",
|
|
||||||
"destructiveForeground": "0 0% 100%",
|
|
||||||
"border": "30 6% 86%",
|
|
||||||
"input": "30 6% 88%",
|
|
||||||
"ring": "30 8% 30%"
|
|
||||||
},
|
},
|
||||||
"darkColors": {
|
"darkColors": {
|
||||||
"background": "30 6% 4%",
|
"background": "0 0% 5%",
|
||||||
"foreground": "30 6% 92%",
|
"foreground": "0 0% 90%",
|
||||||
"card": "30 6% 7%",
|
"card": "0 0% 8%",
|
||||||
"cardForeground": "30 6% 92%",
|
"cardForeground": "0 0% 90%",
|
||||||
"popover": "30 6% 9%",
|
"popover": "0 0% 8%",
|
||||||
"popoverForeground": "30 6% 92%",
|
"popoverForeground": "0 0% 90%",
|
||||||
"primary": "30 6% 96%",
|
"primary": "2 72% 54%",
|
||||||
"primaryForeground": "30 8% 6%",
|
"primaryForeground": "0 0% 6%",
|
||||||
"secondary": "30 6% 12%",
|
"secondary": "0 0% 14%",
|
||||||
"secondaryForeground": "30 6% 88%",
|
"secondaryForeground": "0 0% 86%",
|
||||||
"muted": "30 6% 10%",
|
"muted": "0 0% 12%",
|
||||||
"mutedForeground": "30 4% 58%",
|
"mutedForeground": "0 0% 58%",
|
||||||
"accent": "30 8% 70%",
|
"accent": "0 0% 88%",
|
||||||
"accentForeground": "30 8% 6%",
|
"accentForeground": "0 0% 8%",
|
||||||
"destructive": "0 70% 45%",
|
"destructive": "2 74% 52%",
|
||||||
"destructiveForeground": "0 0% 98%",
|
"destructiveForeground": "0 0% 92%",
|
||||||
"border": "30 6% 16%",
|
"border": "0 0% 19%",
|
||||||
"input": "30 6% 14%",
|
"input": "0 0% 16%",
|
||||||
"ring": "30 8% 72%"
|
"ring": "2 72% 54%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
preview.png
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 1.4 MiB |
176
register.go
@ -1,176 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
|
||||||
"git.dev.alexdunmow.com/block/core/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// wrap adapts a templ-returning render function to templates.TemplateFunc.
|
|
||||||
// templ.Component already implements templates.HTMLComponent via Render.
|
|
||||||
func wrap(f func(ctx context.Context, doc map[string]any) templ.Component) templates.TemplateFunc {
|
|
||||||
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
|
|
||||||
return f(ctx, doc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register wires the Noir system template, page templates, blocks, overrides,
|
|
||||||
// and email wrapper into the host registries.
|
|
||||||
//
|
|
||||||
// Order matters: br.LoadSchemasFromFS(Schemas()) is called BEFORE any
|
|
||||||
// br.Register call so the schema metadata binds correctly.
|
|
||||||
func Register(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
|
|
||||||
// System template.
|
|
||||||
tr.RegisterSystemTemplate(templates.SystemTemplateMeta{
|
|
||||||
Key: "noir",
|
|
||||||
Title: "Noir",
|
|
||||||
Description: "Silver-on-black photography theme with full-bleed imagery, micro mono labels, lightbox galleries and contact-sheet layouts.",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Page templates (4).
|
|
||||||
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
|
|
||||||
Key: "default",
|
|
||||||
Title: "Default",
|
|
||||||
Description: "Centred gallery page with a thin masthead and dissolved footer.",
|
|
||||||
Slots: []string{"header", "main", "footer"},
|
|
||||||
}, wrap(RenderNoirDefault)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
|
|
||||||
Key: "landing",
|
|
||||||
Title: "Landing",
|
|
||||||
Description: "Edge-to-edge hero image, micro caption strip, minimal CTA.",
|
|
||||||
Slots: []string{"hero", "main", "cta", "footer"},
|
|
||||||
}, wrap(RenderNoirLanding)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
|
|
||||||
Key: "article",
|
|
||||||
Title: "Article / Project",
|
|
||||||
Description: "Long-form case study with sticky caption rail and image-led prose.",
|
|
||||||
Slots: []string{"header", "main", "aside", "footer"},
|
|
||||||
}, wrap(RenderNoirArticle)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
|
|
||||||
Key: "full-width",
|
|
||||||
Title: "Full Width",
|
|
||||||
Description: "Contact-sheet or lightbox grid, no horizontal padding.",
|
|
||||||
Slots: []string{"header", "main", "footer"},
|
|
||||||
}, wrap(RenderNoirFullWidth)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schemas must load BEFORE block registration so metadata binds.
|
|
||||||
if err := br.LoadSchemasFromFS(Schemas()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme-specific blocks (6). Registered with unqualified keys —
|
|
||||||
// addressed as "noir:<key>" at runtime.
|
|
||||||
br.Register(LightboxGalleryBlockMeta, LightboxGalleryBlock)
|
|
||||||
br.Register(ContactSheetBlockMeta, ContactSheetBlock)
|
|
||||||
br.Register(CaseStudyBlockMeta, CaseStudyBlock)
|
|
||||||
br.Register(CaptionStripBlockMeta, CaptionStripBlock)
|
|
||||||
br.Register(ImagePairBlockMeta, ImagePairBlock)
|
|
||||||
br.Register(FooterBlockMeta, FooterBlock)
|
|
||||||
|
|
||||||
// Template overrides (5) — active only when this theme is selected.
|
|
||||||
br.RegisterTemplateOverride("noir", "heading", NoirHeadingBlock)
|
|
||||||
br.RegisterTemplateOverride("noir", "text", NoirTextBlock)
|
|
||||||
br.RegisterTemplateOverride("noir", "image", NoirImageBlock)
|
|
||||||
br.RegisterTemplateOverride("noir", "button", NoirButtonBlock)
|
|
||||||
br.RegisterTemplateOverride("noir", "card", NoirCardBlock)
|
|
||||||
|
|
||||||
// Branded email wrapper.
|
|
||||||
tr.RegisterEmailWrapper("noir", NoirEmailWrapper)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultMasterPages returns the two master pages Noir provisions on first load.
|
|
||||||
//
|
|
||||||
// noir:default-master covers the `default` and `article` page templates.
|
|
||||||
// noir:gallery-master covers `landing` and `full-width`, swapping the
|
|
||||||
// caption strip for a contact-sheet-style footer and using the dissolved navbar.
|
|
||||||
func DefaultMasterPages() []plugin.MasterPageDefinition {
|
|
||||||
return []plugin.MasterPageDefinition{
|
|
||||||
{
|
|
||||||
Key: "noir:default-master",
|
|
||||||
Title: "Noir Default Master",
|
|
||||||
PageTemplates: []string{"default", "article"},
|
|
||||||
Blocks: []plugin.MasterPageBlock{
|
|
||||||
{
|
|
||||||
BlockKey: "navbar",
|
|
||||||
Title: "Masthead",
|
|
||||||
Content: map[string]any{"menuName": "main", "variant": "minimal"},
|
|
||||||
Slot: "header",
|
|
||||||
SortOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
BlockKey: "slot",
|
|
||||||
Title: "Main Slot",
|
|
||||||
Content: map[string]any{"slotName": "main", "placeholder": "Photographs go here"},
|
|
||||||
Slot: "main",
|
|
||||||
SortOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
BlockKey: "noir:caption_strip",
|
|
||||||
Title: "Caption Strip",
|
|
||||||
Content: map[string]any{"label": "INDEX", "right": "© studio"},
|
|
||||||
Slot: "footer",
|
|
||||||
SortOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
BlockKey: "noir:footer",
|
|
||||||
Title: "Footer",
|
|
||||||
Content: map[string]any{"showColophon": "true"},
|
|
||||||
Slot: "footer",
|
|
||||||
SortOrder: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Key: "noir:gallery-master",
|
|
||||||
Title: "Noir Gallery Master",
|
|
||||||
PageTemplates: []string{"landing", "full-width"},
|
|
||||||
Blocks: []plugin.MasterPageBlock{
|
|
||||||
{
|
|
||||||
BlockKey: "navbar",
|
|
||||||
Title: "Dissolved Masthead",
|
|
||||||
Content: map[string]any{"menuName": "main", "variant": "dissolved"},
|
|
||||||
Slot: "header",
|
|
||||||
SortOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
BlockKey: "slot",
|
|
||||||
Title: "Main Slot",
|
|
||||||
Content: map[string]any{"slotName": "main", "placeholder": "Photographs go here"},
|
|
||||||
Slot: "main",
|
|
||||||
SortOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
BlockKey: "noir:contact_sheet_footer",
|
|
||||||
Title: "Contact Sheet Footer",
|
|
||||||
Content: map[string]any{"items": []any{}},
|
|
||||||
Slot: "footer",
|
|
||||||
SortOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
BlockKey: "noir:footer",
|
|
||||||
Title: "Footer",
|
|
||||||
Content: map[string]any{"showColophon": "true"},
|
|
||||||
Slot: "footer",
|
|
||||||
SortOrder: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
|
||||||
"git.dev.alexdunmow.com/block/core/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Registration is the compile-time plugin registration for the Noir theme.
|
|
||||||
var Registration = plugin.PluginRegistration{
|
|
||||||
Name: "noir",
|
|
||||||
Version: plugin.ParseModVersion(pluginModBytes),
|
|
||||||
Register: func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
|
|
||||||
return Register(tr, br)
|
|
||||||
},
|
|
||||||
Assets: func() http.Handler { return AssetsHandler() },
|
|
||||||
Schemas: func() fs.FS { return Schemas() },
|
|
||||||
ThemePresets: func() []byte { return ThemePresets() },
|
|
||||||
BundledFonts: func() []byte { return BundledFonts() },
|
|
||||||
MasterPages: func() []plugin.MasterPageDefinition { return DefaultMasterPages() },
|
|
||||||
CSSManifest: func() *plugin.CSSManifest { return ThemeCSSManifest() },
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"title": "Caption Strip",
|
|
||||||
"description": "Full-width 10px mono caption strip with a label on the left and supporting text on the right.",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"label": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Label",
|
|
||||||
"description": "Left-aligned label (e.g. 'INDEX', 'PLATE 01').",
|
|
||||||
"x-editor": "text"
|
|
||||||
},
|
|
||||||
"right": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Right Text",
|
|
||||||
"description": "Right-aligned supporting text (e.g. copyright, date).",
|
|
||||||
"x-editor": "text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"title": "Project Case Study",
|
|
||||||
"description": "Long-form project spread with sticky meta rail and image-led prose.",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Project Title",
|
|
||||||
"description": "Display title for the project.",
|
|
||||||
"x-editor": "text"
|
|
||||||
},
|
|
||||||
"client": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Client",
|
|
||||||
"description": "Client or commissioning party.",
|
|
||||||
"x-editor": "text"
|
|
||||||
},
|
|
||||||
"year": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Year",
|
|
||||||
"description": "Year of the project.",
|
|
||||||
"x-editor": "number"
|
|
||||||
},
|
|
||||||
"credits": {
|
|
||||||
"type": "array",
|
|
||||||
"title": "Credits",
|
|
||||||
"description": "Production credits, one entry per line.",
|
|
||||||
"x-editor": "array",
|
|
||||||
"default": [],
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"x-editor": "text"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"images": {
|
|
||||||
"type": "array",
|
|
||||||
"title": "Images",
|
|
||||||
"description": "Photographs in the case study spread.",
|
|
||||||
"x-editor": "array",
|
|
||||||
"default": [],
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"x-editor": "media"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"title": "Contact Sheet",
|
|
||||||
"description": "Numbered photograph frames with sprocket motif, evoking a darkroom contact sheet.",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"items": {
|
|
||||||
"type": "array",
|
|
||||||
"title": "Frames",
|
|
||||||
"description": "Numbered photograph frames.",
|
|
||||||
"x-editor": "array",
|
|
||||||
"default": [],
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"image": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Image",
|
|
||||||
"description": "Photograph in this frame.",
|
|
||||||
"x-editor": "media"
|
|
||||||
},
|
|
||||||
"frame": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Frame Number",
|
|
||||||
"description": "Frame number or roll identifier (e.g. '12A', '027').",
|
|
||||||
"x-editor": "text"
|
|
||||||
},
|
|
||||||
"label": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Label",
|
|
||||||
"description": "Short caption shown alongside the frame number.",
|
|
||||||
"x-editor": "text"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["image"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"title": "Noir Footer",
|
|
||||||
"description": "Dissolved bottom rail with optional colophon and social links.",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"showColophon": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Show Colophon",
|
|
||||||
"description": "Render the colophon block beneath the footer rail.",
|
|
||||||
"x-editor": "select",
|
|
||||||
"enum": ["true", "false"],
|
|
||||||
"default": "true"
|
|
||||||
},
|
|
||||||
"social": {
|
|
||||||
"type": "array",
|
|
||||||
"title": "Social Links",
|
|
||||||
"description": "Optional social media links shown on the right of the footer rail.",
|
|
||||||
"x-editor": "array",
|
|
||||||
"default": [],
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"x-editor": "link",
|
|
||||||
"properties": {
|
|
||||||
"text": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Label",
|
|
||||||
"x-editor": "text"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "URL",
|
|
||||||
"x-editor": "text"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["text", "url"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"title": "Image Pair",
|
|
||||||
"description": "50/50 photograph diptych with a shared caption beneath.",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"left": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Left Image",
|
|
||||||
"description": "Left photograph in the diptych.",
|
|
||||||
"x-editor": "media"
|
|
||||||
},
|
|
||||||
"right": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Right Image",
|
|
||||||
"description": "Right photograph in the diptych.",
|
|
||||||
"x-editor": "media"
|
|
||||||
},
|
|
||||||
"caption": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Caption",
|
|
||||||
"description": "Shared caption shown beneath both images.",
|
|
||||||
"x-editor": "text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"title": "Lightbox Gallery",
|
|
||||||
"description": "Grid of photographs that expand to a full-viewport lightbox overlay on click.",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"items": {
|
|
||||||
"type": "array",
|
|
||||||
"title": "Photographs",
|
|
||||||
"description": "Image entries with optional captions.",
|
|
||||||
"x-editor": "array",
|
|
||||||
"default": [],
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"image": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Image",
|
|
||||||
"description": "Photograph to display.",
|
|
||||||
"x-editor": "media"
|
|
||||||
},
|
|
||||||
"caption": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Caption",
|
|
||||||
"description": "Optional caption shown beneath the image and in the lightbox.",
|
|
||||||
"x-editor": "text"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["image"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"columns": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Columns",
|
|
||||||
"description": "Number of columns on desktop (2, 3, or 4).",
|
|
||||||
"x-editor": "select",
|
|
||||||
"enum": [2, 3, 4],
|
|
||||||
"default": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
screenshots/01-home-light.png
Normal file
|
After Width: | Height: | Size: 1001 KiB |
BIN
screenshots/02-home-dark.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
screenshots/03-about-light.png
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
screenshots/04-about-dark.png
Normal file
|
After Width: | Height: | Size: 606 KiB |
BIN
screenshots/05-blog-the-harbour-light-light.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
screenshots/06-blog-the-harbour-light-dark.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
seed/media/foggy-night.jpg
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
seed/media/foggy-street.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
seed/media/harbour-night.jpg
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
seed/media/streetlamp.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
seed/media/typewriter-keys.jpg
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
seed/media/typewriter.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
242
seed/seed.json
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
{
|
||||||
|
"demo": true,
|
||||||
|
"settings": {
|
||||||
|
"merge": {
|
||||||
|
"companyName": "The Cold File",
|
||||||
|
"tagline": "Cases that were never closed."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"media": [
|
||||||
|
{ "id": "7a1c0001-0000-4000-8000-000000000001", "file": "media/harbour-night.jpg", "alt": "A working harbour at night under sodium light", "folder": "noir" },
|
||||||
|
{ "id": "7a1c0001-0000-4000-8000-000000000002", "file": "media/foggy-night.jpg", "alt": "A deserted street lost in night fog", "folder": "noir" },
|
||||||
|
{ "id": "7a1c0001-0000-4000-8000-000000000003", "file": "media/typewriter.jpg", "alt": "An old typewriter on a desk", "folder": "noir" },
|
||||||
|
{ "id": "7a1c0001-0000-4000-8000-000000000004", "file": "media/streetlamp.jpg", "alt": "A single streetlamp in the dark", "folder": "noir" },
|
||||||
|
{ "id": "7a1c0001-0000-4000-8000-000000000005", "file": "media/foggy-street.jpg", "alt": "A wet street under fog and lamplight", "folder": "noir" },
|
||||||
|
{ "id": "7a1c0001-0000-4000-8000-000000000006", "file": "media/typewriter-keys.jpg", "alt": "Close detail of typewriter keys", "folder": "noir" }
|
||||||
|
],
|
||||||
|
"data_tables": [
|
||||||
|
{
|
||||||
|
"key": "case_tips",
|
||||||
|
"name": "Case Tips",
|
||||||
|
"description": "Seeded tip-line submissions for The Cold File demo. A submission becomes a row here and emails the site admins.",
|
||||||
|
"columns": [
|
||||||
|
{ "key": "name", "label": "Name", "type": "text", "required": false },
|
||||||
|
{ "key": "email", "label": "Email", "type": "email", "required": true },
|
||||||
|
{ "key": "subject", "label": "Case or subject", "type": "text", "required": false },
|
||||||
|
{ "key": "message", "label": "Message", "type": "text", "required": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"slug": "/",
|
||||||
|
"title": "The Cold File",
|
||||||
|
"template_key": "landing",
|
||||||
|
"reconcile_blocks": true,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"block_key": "hero",
|
||||||
|
"title": "Hero",
|
||||||
|
"slot": "hero",
|
||||||
|
"sort_order": 1,
|
||||||
|
"content": {
|
||||||
|
"template": "centered",
|
||||||
|
"backgroundImage": "7a1c0001-0000-4000-8000-000000000001",
|
||||||
|
"overlayColor": "dark",
|
||||||
|
"overlayOpacity": 55,
|
||||||
|
"eyebrow": "A true-crime file",
|
||||||
|
"headline": "The Cold File",
|
||||||
|
"subheadline": "Cases that were never closed.",
|
||||||
|
"description": "We read the record, walk the ground, and lay out what is known. One case at a time. You decide what it adds up to.",
|
||||||
|
"ctaPrimary": { "text": "Read the files", "url": "/blog" },
|
||||||
|
"ctaSecondary": { "text": "Submit a tip", "url": "/contact" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"block_key": "noir:case_file",
|
||||||
|
"title": "Featured case",
|
||||||
|
"slot": "main",
|
||||||
|
"sort_order": 2,
|
||||||
|
"content": {
|
||||||
|
"caseNumber": "CF-0451",
|
||||||
|
"subject": "The Harbour Light",
|
||||||
|
"status": "cold",
|
||||||
|
"summary": "A night watchman clocked out at two and never reached the gate. His lamp was still burning at dawn. The tide had turned twice.",
|
||||||
|
"fields": [
|
||||||
|
{ "label": "Filed", "value": "March 1998" },
|
||||||
|
{ "label": "Location", "value": "Pier 9, East Dock" },
|
||||||
|
{ "label": "Last contact", "value": "02:04, radio check" },
|
||||||
|
{ "label": "Status", "value": "Open, unsolved" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"block_key": "feature-grid",
|
||||||
|
"title": "How we work",
|
||||||
|
"slot": "main",
|
||||||
|
"sort_order": 3,
|
||||||
|
"content": {
|
||||||
|
"eyebrow": "Method",
|
||||||
|
"heading": "How we work a file",
|
||||||
|
"intro": "No theory before the record. We build the case from what can be checked.",
|
||||||
|
"columns": 3,
|
||||||
|
"layout": "card",
|
||||||
|
"items": [
|
||||||
|
{ "icon": "lucide:file-text", "title": "Read the record", "text": "Reports, transcripts, and the file as it was left. We start where the paper starts." },
|
||||||
|
{ "icon": "lucide:map-pin", "title": "Walk the ground", "text": "We stand where it happened and check the file against the place." },
|
||||||
|
{ "icon": "lucide:search", "title": "Follow the thread", "text": "One fact at a time. We show our work and mark what stays unknown." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"block_key": "noir:redacted_quote",
|
||||||
|
"title": "Transcript",
|
||||||
|
"slot": "main",
|
||||||
|
"sort_order": 4,
|
||||||
|
"content": {
|
||||||
|
"label": "Interview 3, page 12",
|
||||||
|
"quote": "I told them what I saw at the water. They wrote it down and the name came back",
|
||||||
|
"redacted": "withheld pending review",
|
||||||
|
"attribution": "Witness statement",
|
||||||
|
"source": "Case CF-0451, sealed appendix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"block_key": "cta",
|
||||||
|
"title": "Closing CTA",
|
||||||
|
"slot": "cta",
|
||||||
|
"sort_order": 1,
|
||||||
|
"content": {
|
||||||
|
"background": "band",
|
||||||
|
"heading": "Know something we don't?",
|
||||||
|
"text": "Tips are read in confidence. Names stay off the page unless you ask otherwise.",
|
||||||
|
"primaryLabel": "Submit a tip",
|
||||||
|
"primaryUrl": "/contact",
|
||||||
|
"secondaryLabel": "Read the files",
|
||||||
|
"secondaryUrl": "/blog"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "/about",
|
||||||
|
"title": "About the File",
|
||||||
|
"template_key": "default",
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "heading", "title": "Heading", "slot": "main", "sort_order": 1, "content": { "text": "About the File", "level": 1 } },
|
||||||
|
{ "block_key": "rich-text", "title": "Story", "slot": "main", "sort_order": 2, "content": { "html": "<p>The Cold File is a true-crime publication. We take one unsolved case at a time and set it out plainly, from the record up.</p><p>We do not name the guilty. We do not sell a theory. We show what is known, mark what is not, and leave the reader to weigh it. When new facts arrive, the file is updated in the open.</p>" } },
|
||||||
|
{ "block_key": "image", "title": "The desk", "slot": "main", "sort_order": 3, "content": { "src": "7a1c0001-0000-4000-8000-000000000003", "alt": "The desk where the files are read", "caption": "The desk. Every file starts here.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
|
||||||
|
{ "block_key": "divider", "title": "Divider", "slot": "main", "sort_order": 4, "content": { "variant": "line", "weight": "thick", "width": "narrow" } },
|
||||||
|
{ "block_key": "quote", "title": "Quote", "slot": "main", "sort_order": 5, "content": { "style": "pull", "align": "center", "quote": "A cold case is not a closed one. It is a file waiting for one more fact.", "attribution": "The Cold File" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "/blog",
|
||||||
|
"title": "The Case Files",
|
||||||
|
"template_key": "blog-index",
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "card", "title": "Case: Harbour Light", "slot": "main", "sort_order": 1, "content": { "layout": "vertical", "title": "The Harbour Light", "text": "<p>A watchman clocked out and never reached the gate. The lamp burned until dawn.</p>", "link": "/blog/the-harbour-light", "linkLabel": "Open the file" } },
|
||||||
|
{ "block_key": "card", "title": "Case: Room 14", "slot": "main", "sort_order": 2, "content": { "layout": "vertical", "title": "Room 14", "text": "<p>A guest checked in under a false name and left one suitcase behind. Nobody saw the door open.</p>", "link": "/blog/room-14", "linkLabel": "Open the file" } },
|
||||||
|
{ "block_key": "card", "title": "Case: The Last Bus", "slot": "main", "sort_order": 3, "content": { "layout": "vertical", "title": "The Last Bus", "text": "<p>The driver logged the final stop on time. The bus reached the depot empty and locked from inside.</p>", "link": "/blog/the-last-bus", "linkLabel": "Open the file" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "/blog/the-harbour-light",
|
||||||
|
"title": "The Harbour Light",
|
||||||
|
"template_key": "article",
|
||||||
|
"parent_slug": "/blog",
|
||||||
|
"reconcile_blocks": true,
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "The Harbour Light", "level": 1 } },
|
||||||
|
{ "block_key": "image", "title": "The pier", "slot": "main", "sort_order": 2, "content": { "src": "7a1c0001-0000-4000-8000-000000000002", "alt": "The East Dock lost in night fog", "caption": "Pier 9, East Dock — the same hour we walked it.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
|
||||||
|
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 3, "content": { "html": "<p>The watchman made his last radio check at four minutes past two. He was due at the gate by half past. He never signed out.</p><p>The lamp on Pier 9 was still lit at dawn. The tide had turned twice. No boat was reported missing. No body was found. The file was opened, worked for a season, and set aside.</p><p>Start with what the record holds. A duty roster with his name in the two-to-six column. A radio log with four entries in his hand, the last at 02:04, marked routine. A gate ledger that shows every man off the dock by three — every man but one.</p><p>The season it was worked, the file gathered the usual paper. Two dozen interviews. A diver's report for the water off the pier head, twice. A note from the gate man, who swore the light was burning when he cycled past at ten to three and swore just as hard that nobody stood under it.</p><p>Then the file stops. Not closed — stopped. The last page is a requisition for more diving time that nobody signed.</p><p>We pulled the harbour master's log and the tide tables for that week. The two do not agree on when the water turned. One of them was written up after the fact, and we think we know which.</p><p>We walked the pier at the same hour. The lamp throws light about forty feet. The gate is ninety. For six of those steps a man is in the dark. We are still checking what stands in them.</p><p>There is one more thing the record holds, and we set it down here exactly as it appears. The radio log's final line, in a different hand, timed 02:31: <em>checked pier, all quiet</em>. Nobody at the harbour that season would say whose hand it was.</p><p>The file is open. If you worked the East Dock in March 1998, or knew someone who did, the tip line is at the end of this page.</p>" } },
|
||||||
|
{ "block_key": "heading", "title": "Rail heading", "slot": "aside", "sort_order": 1, "content": { "text": "Case File", "level": 3 } },
|
||||||
|
{ "block_key": "text", "title": "Case metadata", "slot": "aside", "sort_order": 2, "content": { "text": "<dl class=\"not-prose space-y-4\"><div><dt class=\"noir-label text-muted-foreground\">Case No.</dt><dd class=\"noir-mono text-sm text-foreground\">CF-0451</dd></div><div><dt class=\"noir-label text-muted-foreground\">Filed</dt><dd class=\"noir-mono text-sm text-foreground\">March 1998</dd></div><div><dt class=\"noir-label text-muted-foreground\">Location</dt><dd class=\"noir-mono text-sm text-foreground\">Pier 9, East Dock</dd></div><div><dt class=\"noir-label text-muted-foreground\">Last contact</dt><dd class=\"noir-mono text-sm text-foreground\">02:04, radio check</dd></div><div><dt class=\"noir-label text-muted-foreground\">Status</dt><dd class=\"noir-mono text-sm text-foreground\">Open, unsolved</dd></div></dl>" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "/blog/room-14",
|
||||||
|
"title": "Room 14",
|
||||||
|
"template_key": "article",
|
||||||
|
"parent_slug": "/blog",
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "Room 14", "level": 1 } },
|
||||||
|
{ "block_key": "image", "title": "The hall", "slot": "main", "sort_order": 2, "content": { "src": "7a1c0001-0000-4000-8000-000000000004", "alt": "A single lamp at the quiet end of the hall", "caption": "The quiet end of the hall. One lamp, kept lit.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
|
||||||
|
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 3, "content": { "html": "<p>The guest signed the register in a hand that did not match the name. He paid a week in advance and asked for the quiet end of the hall.</p><p>On the fourth night the room went dark and stayed that way. The suitcase was still there. The bed was made. The window was latched from the inside.</p><p>The file lists three people who saw him arrive and none who saw him leave. The night porter swears the corridor was watched the whole shift. The register says otherwise, in the same hand that signed the wrong name.</p><p>We are trying to reconcile that. So far the only thing everyone agrees on is the smell of a struck match, and no one who admits to lighting one.</p>" } },
|
||||||
|
{ "block_key": "heading", "title": "Rail heading", "slot": "aside", "sort_order": 1, "content": { "text": "Case File", "level": 3 } },
|
||||||
|
{ "block_key": "text", "title": "Case metadata", "slot": "aside", "sort_order": 2, "content": { "text": "<dl class=\"not-prose space-y-4\"><div><dt class=\"noir-label text-muted-foreground\">Case No.</dt><dd class=\"noir-mono text-sm text-foreground\">CF-0212</dd></div><div><dt class=\"noir-label text-muted-foreground\">Filed</dt><dd class=\"noir-mono text-sm text-foreground\">November 2003</dd></div><div><dt class=\"noir-label text-muted-foreground\">Location</dt><dd class=\"noir-mono text-sm text-foreground\">The Old Exchange</dd></div><div><dt class=\"noir-label text-muted-foreground\">Last seen</dt><dd class=\"noir-mono text-sm text-foreground\">Night four, 21:10</dd></div><div><dt class=\"noir-label text-muted-foreground\">Status</dt><dd class=\"noir-mono text-sm text-foreground\">Open, unsolved</dd></div></dl>" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "/blog/the-last-bus",
|
||||||
|
"title": "The Last Bus",
|
||||||
|
"template_key": "article",
|
||||||
|
"parent_slug": "/blog",
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "The Last Bus", "level": 1 } },
|
||||||
|
{ "block_key": "image", "title": "The route", "slot": "main", "sort_order": 2, "content": { "src": "7a1c0001-0000-4000-8000-000000000005", "alt": "A wet street under lamplight on the last route", "caption": "The last route, run again at the same hour.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
|
||||||
|
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 3, "content": { "html": "<p>The driver logged the final stop on schedule. Twelve minutes later the bus rolled into the depot. It was empty and the doors were locked from inside.</p><p>The fare box held the right count for a full run. The seats were cold. The last passenger on the ticket record never reached home.</p><p>We drove the route ourselves at the same hour and clocked every stop. Between the second-last and the depot there is a stretch with no lights and no camera. It takes about twelve minutes.</p><p>We have the route, the timing, and the log. What we do not have is what happened in those twelve minutes, or why the doors were locked from the inside.</p>" } },
|
||||||
|
{ "block_key": "heading", "title": "Rail heading", "slot": "aside", "sort_order": 1, "content": { "text": "Case File", "level": 3 } },
|
||||||
|
{ "block_key": "text", "title": "Case metadata", "slot": "aside", "sort_order": 2, "content": { "text": "<dl class=\"not-prose space-y-4\"><div><dt class=\"noir-label text-muted-foreground\">Case No.</dt><dd class=\"noir-mono text-sm text-foreground\">CF-0388</dd></div><div><dt class=\"noir-label text-muted-foreground\">Filed</dt><dd class=\"noir-mono text-sm text-foreground\">July 2009</dd></div><div><dt class=\"noir-label text-muted-foreground\">Route</dt><dd class=\"noir-mono text-sm text-foreground\">Night 9, outbound</dd></div><div><dt class=\"noir-label text-muted-foreground\">Depot</dt><dd class=\"noir-mono text-sm text-foreground\">00:12, locked</dd></div><div><dt class=\"noir-label text-muted-foreground\">Status</dt><dd class=\"noir-mono text-sm text-foreground\">Open, unsolved</dd></div></dl>" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "/contact",
|
||||||
|
"title": "Tip Line",
|
||||||
|
"template_key": "contact",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"block_key": "contact-form",
|
||||||
|
"title": "Tip Line",
|
||||||
|
"slot": "main",
|
||||||
|
"sort_order": 1,
|
||||||
|
"content": {
|
||||||
|
"heading": "Submit a tip",
|
||||||
|
"description": "Tips are read in confidence. Tell us the case and what you know. Leave contact details only if you want a reply.",
|
||||||
|
"submitLabel": "Send tip",
|
||||||
|
"successMessage": "Thank you. Your tip has been logged. We read every one.",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "name", "label": "Your name", "type": "text", "required": false, "width": "half" },
|
||||||
|
{ "name": "email", "label": "Email", "type": "email", "required": true, "width": "half" },
|
||||||
|
{ "name": "subject", "label": "Case or subject", "type": "text", "required": false, "width": "full" },
|
||||||
|
{ "name": "message", "label": "What do you know?", "type": "textarea", "required": true, "width": "full" }
|
||||||
|
],
|
||||||
|
"formConfig": { "enabled": true, "captchaEnabled": false, "targetTable": "case_tips" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"block_key": "hours-location",
|
||||||
|
"title": "Where to find us",
|
||||||
|
"slot": "aside",
|
||||||
|
"sort_order": 1,
|
||||||
|
"content": {
|
||||||
|
"heading": "The Desk",
|
||||||
|
"hours": [
|
||||||
|
{ "day": "Monday to Friday", "hours": "9am to 6pm" },
|
||||||
|
{ "day": "Saturday", "hours": "By appointment" },
|
||||||
|
{ "day": "Sunday", "closed": true }
|
||||||
|
],
|
||||||
|
"specialHoursNote": "Tips are read outside these hours. Replies are not.",
|
||||||
|
"address": "Room 14, The Old Exchange\nEast Dock",
|
||||||
|
"phone": "+1 555 0199"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "/login",
|
||||||
|
"title": "Access",
|
||||||
|
"template_key": "auth",
|
||||||
|
"blocks": [
|
||||||
|
{ "block_key": "auth-form", "title": "Sign in", "slot": "main", "sort_order": 1, "content": { "default_tab": "login", "show_social": false } },
|
||||||
|
{ "block_key": "text", "title": "Foot", "slot": "footer", "sort_order": 1, "content": { "text": "<p>Registered readers get new files first and the sealed appendices as they clear review.</p>" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"menu_items": [
|
||||||
|
{ "menu": "main", "label": "Home", "page_slug": "/", "sort_order": 1 },
|
||||||
|
{ "menu": "main", "label": "The Case Files", "page_slug": "/blog", "sort_order": 2 },
|
||||||
|
{ "menu": "main", "label": "About the File", "page_slug": "/about", "sort_order": 3 },
|
||||||
|
{ "menu": "main", "label": "Tip Line", "page_slug": "/contact", "sort_order": 4 }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
seed/workflows.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"workflows": [
|
||||||
|
{
|
||||||
|
"key": "case_tip_notify_admins",
|
||||||
|
"name": "Case tip to admins",
|
||||||
|
"description": "On a tip-line submission, email the site admins.",
|
||||||
|
"enabled": true,
|
||||||
|
"trigger": { "type": "row_inserted", "table": "case_tips" },
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "email",
|
||||||
|
"name": "Email admins",
|
||||||
|
"template": "Form Submission Notification",
|
||||||
|
"recipients": { "mode": "admins", "scope": "all" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
288
template.templ
@ -1,288 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@ -1,548 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package main
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
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.
|
|
||||||
func noirHead(data NoirPageData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = 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,
|
|
||||||
}).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// noirLightboxScript injects the vanilla keyboard-aware lightbox handler.
|
|
||||||
// Stays tiny (no deps) and uses the data attributes set by lightbox_gallery.
|
|
||||||
func noirLightboxScript() templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var2 == nil {
|
|
||||||
templ_7745c5c3_Var2 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n\t\t(function() {\n\t\t if (window.__noirLightboxInit) return;\n\t\t window.__noirLightboxInit = true;\n\t\t var overlay = null;\n\t\t function ensureOverlay() {\n\t\t if (overlay) return overlay;\n\t\t overlay = document.createElement('div');\n\t\t overlay.setAttribute('data-noir-lightbox', '');\n\t\t overlay.setAttribute('aria-hidden', 'true');\n\t\t overlay.setAttribute('role', 'dialog');\n\t\t overlay.setAttribute('aria-modal', 'true');\n\t\t 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>';\n\t\t document.body.appendChild(overlay);\n\t\t overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); });\n\t\t overlay.querySelector('.noir-lightbox-close').addEventListener('click', close);\n\t\t document.addEventListener('keydown', function(e) {\n\t\t if (overlay.getAttribute('aria-hidden') === 'false' && e.key === 'Escape') close();\n\t\t });\n\t\t return overlay;\n\t\t }\n\t\t function open(src, alt, caption) {\n\t\t var o = ensureOverlay();\n\t\t var img = o.querySelector('img');\n\t\t img.src = src;\n\t\t img.alt = alt || '';\n\t\t o.querySelector('.noir-lightbox-caption').textContent = caption || '';\n\t\t o.setAttribute('aria-hidden', 'false');\n\t\t o.querySelector('.noir-lightbox-close').focus();\n\t\t }\n\t\t function close() {\n\t\t if (!overlay) return;\n\t\t overlay.setAttribute('aria-hidden', 'true');\n\t\t }\n\t\t document.addEventListener('click', function(e) {\n\t\t var t = e.target.closest('[data-noir-lightbox-trigger]');\n\t\t if (!t) return;\n\t\t e.preventDefault();\n\t\t open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));\n\t\t });\n\t\t document.addEventListener('keydown', function(e) {\n\t\t if (e.key !== 'Enter' && e.key !== ' ') return;\n\t\t var t = document.activeElement;\n\t\t if (t && t.matches && t.matches('[data-noir-lightbox-trigger]')) {\n\t\t e.preventDefault();\n\t\t open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));\n\t\t }\n\t\t });\n\t\t})();\n\t</script>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirDefault — centred gallery page with thin masthead and dissolved footer.
|
|
||||||
func NoirDefault(data NoirPageData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var3 == nil {
|
|
||||||
templ_7745c5c3_Var3 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!doctype html><html lang=\"en\" class=\"dark\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"default\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<header class=\"w-full hairline-b\"><div class=\"max-w-5xl mx-auto px-6\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></header><main class=\"flex-grow max-w-5xl mx-auto w-full px-6 py-12\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
||||||
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"py-24 text-center\"><p class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">No photographs assigned to this page.</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</main><footer class=\"w-full mt-auto\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</footer>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirLanding — edge-to-edge hero, micro caption strip, minimal CTA.
|
|
||||||
func NoirLanding(data NoirPageData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var4 == nil {
|
|
||||||
templ_7745c5c3_Var4 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!doctype html><html lang=\"en\" class=\"dark\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"landing\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<section class=\"w-full\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["hero"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</section><main class=\"flex-grow\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"max-w-5xl mx-auto px-6 py-16\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</main><section class=\"w-full\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["cta"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</section><footer class=\"w-full mt-auto\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</footer>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirArticle — long-form case study with sticky caption rail and image-led prose.
|
|
||||||
func NoirArticle(data NoirPageData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var5 == nil {
|
|
||||||
templ_7745c5c3_Var5 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<!doctype html><html lang=\"en\" class=\"dark\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"article\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<header class=\"w-full hairline-b\"><div class=\"max-w-5xl mx-auto px-6\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</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\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["aside"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside><article class=\"min-w-0\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
||||||
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"py-24 text-center\"><p class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">No content assigned to this article.</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</article></div></main><footer class=\"w-full mt-auto\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</footer>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoirFullWidth — contact-sheet or lightbox grid, no horizontal padding.
|
|
||||||
func NoirFullWidth(data NoirPageData) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var6 == nil {
|
|
||||||
templ_7745c5c3_Var6 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<!doctype html><html lang=\"en\" class=\"dark\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"full-width\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<header class=\"w-full hairline-b\"><div class=\"max-w-5xl mx-auto px-6\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></header><main class=\"flex-grow w-full\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if main, ok := data.Slots["main"]; ok && main != "" {
|
|
||||||
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</main><footer class=\"w-full mt-auto\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</footer>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
69
templates/email/noir.ninjatpl
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<title>{{ site_name }}</title>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td, p, a, li, blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||||
|
body { margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||||
|
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
||||||
|
@media only screen and (max-width: 620px) {
|
||||||
|
.email-container { width: 100% !important; max-width: 100% !important; }
|
||||||
|
.content-padding { padding-left: 24px !important; padding-right: 24px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="background-color: {{ colors.background }}; margin: 0; padding: 0; font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;">
|
||||||
|
{% if preview_text %}<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">{{ preview_text }}</div>{% endif %}
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 32px 10px; background-color: {{ colors.background }};">
|
||||||
|
<table role="presentation" class="email-container" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; background-color: {{ colors.card }}; border: 1px solid {{ colors.border }};">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0; background-color: {{ colors.foreground }};">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 22px 32px 8px; font-family: 'Courier New', Courier, monospace; font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; color: {{ colors.primary }};">Case file</td>
|
||||||
|
</tr>
|
||||||
|
{% if site_name %}<tr><td style="padding: 0 32px 22px; font-family: 'Courier New', Courier, monospace; font-size: 24px; letter-spacing: 0.06em; text-transform: uppercase; color: {{ colors.background }};">{{ site_name }}</td></tr>{% endif %}
|
||||||
|
</table>
|
||||||
|
<div style="height: 3px; background-color: {{ colors.primary }};"> </div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="content-padding" style="padding: 40px 48px; color: {{ colors.foreground }}; font-size: 16px; line-height: 1.7;">{{ body|safe }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0 48px;"><hr style="border: none; border-top: 1px solid {{ colors.border }}; margin: 0;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 24px 48px; color: {{ colors.mutedForeground }}; font-size: 13px;">
|
||||||
|
<p style="margin: 0 0 8px; font-family: 'Courier New', Courier, monospace; text-transform: uppercase; letter-spacing: 0.18em; font-size: 12px; color: {{ colors.foreground }};">{{ site_name }}</p>
|
||||||
|
{% if site_url %}<p style="margin: 0 0 8px;"><a href="{{ site_url }}" style="color: {{ colors.primary }}; text-decoration: none; border-bottom: 1px solid {{ colors.primary }};">{{ site_url }}</a></p>{% endif %}
|
||||||
|
{% if support_email %}<p style="margin: 12px 0 0; color: {{ colors.mutedForeground }};">Tips and enquiries: {{ support_email }}</p>{% endif %}
|
||||||
|
{% if unsubscribe_url %}<p style="margin: 16px 0 0; font-size: 11px;"><a href="{{ unsubscribe_url }}" style="color: {{ colors.mutedForeground }}; text-decoration: none;">Unsubscribe</a></p>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
templates/noir/article.ninjatpl
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
<header class="w-full">
|
||||||
|
<div class="max-w-3xl mx-auto px-4">{{ slots.header|safe }}</div>
|
||||||
|
</header>
|
||||||
|
<div class="flex-grow grid gap-12 max-w-5xl mx-auto w-full px-4 py-12 {% if slots.aside %}md:grid-cols-[1fr_16rem]{% endif %}">
|
||||||
|
<main class="min-w-0 noir-reveal">
|
||||||
|
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}
|
||||||
|
</main>
|
||||||
|
{% if slots.aside %}<aside class="w-full noir-reveal md:border-l md:border-[hsl(var(--border))] md:pl-8">
|
||||||
|
<div class="md:sticky md:top-6">{{ slots.aside|safe }}</div>
|
||||||
|
</aside>{% endif %}
|
||||||
|
</div>
|
||||||
|
<footer class="w-full mt-auto noir-rule">
|
||||||
|
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
|
||||||
|
</footer>
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if(window.__noirReveal)return;window.__noirReveal=1;
|
||||||
|
document.documentElement.classList.add('noir-js');
|
||||||
|
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var els=document.querySelectorAll('.noir-reveal');
|
||||||
|
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
|
||||||
|
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0,rootMargin:'0px 0px -8% 0px'});
|
||||||
|
function showAll(){els.forEach(function(el){el.classList.add('noir-in');});}
|
||||||
|
els.forEach(function(el){var r=el.getBoundingClientRect();var h=window.innerHeight||document.documentElement.clientHeight||0;if(!h||(r.top<h*0.95&&r.bottom>0)){el.classList.add('noir-in');}else{io.observe(el);}});
|
||||||
|
window.addEventListener('load',showAll);
|
||||||
|
setTimeout(showAll,450);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
templates/noir/auth.ninjatpl
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-grain noir-body">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
<main class="flex-grow w-full flex flex-col items-center justify-center px-4 py-12">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<a href="/" class="block text-center" aria-label="{{ site_settings.Title }} home">
|
||||||
|
{% if site_settings.Logo %}<img src="{{ site_settings.Logo }}" alt="{{ site_settings.LogoAlt }}" class="mx-auto h-12 w-auto">{% else %}<span class="noir-heading text-2xl" style="color:hsl(var(--foreground));">{{ site_settings.Title|default:"The Cold File" }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="noir-label mt-3 text-center" style="color:hsl(var(--primary));">Restricted access</div>
|
||||||
|
<div class="mt-6">{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No form assigned to this page.</p></div>{% endif %}</div>
|
||||||
|
{% if slots.footer %}<div class="mt-6 text-center noir-mono text-sm" style="color:hsl(var(--muted-foreground));">{{ slots.footer|safe }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
templates/noir/blog-index.ninjatpl
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
{% if slots.header %}<header class="w-full">{{ slots.header|safe }}</header>{% endif %}
|
||||||
|
<main class="flex-grow w-full">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-12 md:py-16">
|
||||||
|
{% if title %}
|
||||||
|
<div>
|
||||||
|
<div class="noir-label" style="color:hsl(var(--primary));">Case index</div>
|
||||||
|
<h1 class="noir-heading mt-3 text-4xl md:text-5xl" style="color:hsl(var(--foreground));">{{ title }}</h1>
|
||||||
|
{% if page_meta.MetaDescription %}<p class="noir-body mt-4 max-w-2xl text-lg" style="color:hsl(var(--muted-foreground));">{{ page_meta.MetaDescription }}</p>{% endif %}
|
||||||
|
<div class="mt-6 noir-rule"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if slots.featured %}<section class="mt-10 noir-reveal">{{ slots.featured|safe }}</section>{% endif %}
|
||||||
|
{% if slots.main %}<section class="mt-10 noir-reveal">{{ slots.main|safe }}</section>{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No posts published yet.</p></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% if slots.footer %}<footer class="w-full mt-auto noir-rule">{{ slots.footer|safe }}</footer>{% endif %}
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if(window.__noirReveal)return;window.__noirReveal=1;
|
||||||
|
document.documentElement.classList.add('noir-js');
|
||||||
|
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var els=document.querySelectorAll('.noir-reveal');
|
||||||
|
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
|
||||||
|
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0.12});
|
||||||
|
els.forEach(function(el){io.observe(el);});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
templates/noir/contact.ninjatpl
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
{% if slots.header %}<header class="w-full">{{ slots.header|safe }}</header>{% endif %}
|
||||||
|
<main class="flex-grow w-full">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-12 md:py-16">
|
||||||
|
{% if slots.aside %}
|
||||||
|
<div class="grid gap-12 md:grid-cols-[2fr_1fr]">
|
||||||
|
<div class="noir-reveal">{% if slots.main %}{{ slots.main|safe }}{% endif %}</div>
|
||||||
|
<aside class="w-full md:border-l md:border-[hsl(var(--border))] md:pl-10">{{ slots.aside|safe }}</aside>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="max-w-3xl mx-auto noir-reveal">{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% if slots.footer %}<footer class="w-full mt-auto noir-rule">{{ slots.footer|safe }}</footer>{% endif %}
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if(window.__noirReveal)return;window.__noirReveal=1;
|
||||||
|
document.documentElement.classList.add('noir-js');
|
||||||
|
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var els=document.querySelectorAll('.noir-reveal');
|
||||||
|
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
|
||||||
|
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0.12});
|
||||||
|
els.forEach(function(el){io.observe(el);});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
templates/noir/default.ninjatpl
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
<header class="w-full">
|
||||||
|
<div class="max-w-5xl mx-auto px-4">{{ slots.header|safe }}</div>
|
||||||
|
</header>
|
||||||
|
<main class="flex-grow w-full">
|
||||||
|
<div class="max-w-3xl mx-auto w-full px-4 py-12 noir-reveal">
|
||||||
|
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer class="w-full mt-auto noir-rule">
|
||||||
|
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
|
||||||
|
</footer>
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if(window.__noirReveal)return;window.__noirReveal=1;
|
||||||
|
document.documentElement.classList.add('noir-js');
|
||||||
|
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var els=document.querySelectorAll('.noir-reveal');
|
||||||
|
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
|
||||||
|
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0,rootMargin:'0px 0px -8% 0px'});
|
||||||
|
function showAll(){els.forEach(function(el){el.classList.add('noir-in');});}
|
||||||
|
els.forEach(function(el){var r=el.getBoundingClientRect();var h=window.innerHeight||document.documentElement.clientHeight||0;if(!h||(r.top<h*0.95&&r.bottom>0)){el.classList.add('noir-in');}else{io.observe(el);}});
|
||||||
|
window.addEventListener('load',showAll);
|
||||||
|
setTimeout(showAll,450);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
templates/noir/full-width.ninjatpl
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
<header class="w-full">{{ slots.header|safe }}</header>
|
||||||
|
<main class="flex-grow w-full">
|
||||||
|
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}
|
||||||
|
</main>
|
||||||
|
<footer class="w-full mt-auto noir-rule">
|
||||||
|
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
|
||||||
|
</footer>
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if(window.__noirReveal)return;window.__noirReveal=1;
|
||||||
|
document.documentElement.classList.add('noir-js');
|
||||||
|
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var els=document.querySelectorAll('.noir-reveal');
|
||||||
|
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
|
||||||
|
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0.12});
|
||||||
|
els.forEach(function(el){io.observe(el);});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
templates/noir/landing.ninjatpl
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
<section class="w-full relative noir-grain">{{ slots.hero|safe }}</section>
|
||||||
|
<main class="flex-grow">
|
||||||
|
{% if slots.main %}<div class="max-w-3xl mx-auto w-full px-4 py-10 noir-reveal">{{ slots.main|safe }}</div>{% endif %}
|
||||||
|
</main>
|
||||||
|
<section class="w-full">{{ slots.cta|safe }}</section>
|
||||||
|
<footer class="w-full mt-auto noir-rule">
|
||||||
|
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
|
||||||
|
</footer>
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if(window.__noirReveal)return;window.__noirReveal=1;
|
||||||
|
document.documentElement.classList.add('noir-js');
|
||||||
|
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var els=document.querySelectorAll('.noir-reveal');
|
||||||
|
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
|
||||||
|
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0,rootMargin:'0px 0px -8% 0px'});
|
||||||
|
function showAll(){els.forEach(function(el){el.classList.add('noir-in');});}
|
||||||
|
els.forEach(function(el){var r=el.getBoundingClientRect();var h=window.innerHeight||document.documentElement.clientHeight||0;if(!h||(r.top<h*0.95&&r.bottom>0)){el.classList.add('noir-in');}else{io.observe(el);}});
|
||||||
|
window.addEventListener('load',showAll);
|
||||||
|
setTimeout(showAll,450);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
templates/overrides/noir/announcement-bar.ninjatpl
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{# noir override — announcement-bar; preserves variant, message, linkText, linkUrl, dismissable, data-bn-annkey/localStorage dismiss <script> verbatim. #}
|
||||||
|
{% set anncls = "bg-primary/10 text-foreground" %}
|
||||||
|
{% if variant == "promo" %}{% set anncls = "bg-primary text-primary-foreground" %}{% elif variant == "alert" %}{% set anncls = "bg-destructive text-destructive-foreground" %}{% endif %}
|
||||||
|
<div data-block-override="noir:announcement-bar" data-bn-announcement data-bn-annkey="{{ variant|default:'info' }}:{{ message|slugify }}" class="bn-announcement w-full border-b border-border {{ anncls }} {{ class }}">
|
||||||
|
<div class="max-w-7xl mx-auto px-10 py-2 relative flex items-center justify-center gap-2 text-sm text-center">
|
||||||
|
<p class="noir-label">{{ message }}{% if linkText and linkUrl %} <a href="{{ linkUrl }}" class="underline underline-offset-2 hover:opacity-80">{{ linkText }}</a>{% endif %}</p>
|
||||||
|
{% if dismissable %}<button type="button" data-bn-ann-dismiss aria-label="Dismiss announcement" class="absolute right-3 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 rounded-none hover:bg-black/10 transition-colors"><svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if dismissable %}<script>
|
||||||
|
(function(){
|
||||||
|
var bars=document.querySelectorAll('[data-bn-announcement]:not([data-bn-ann-wired])');
|
||||||
|
bars.forEach(function(bar){
|
||||||
|
bar.setAttribute('data-bn-ann-wired','1');
|
||||||
|
var key='bnann:'+bar.getAttribute('data-bn-annkey');
|
||||||
|
try{if(localStorage.getItem(key)==='1'){bar.style.display='none';return;}}catch(e){}
|
||||||
|
var btn=bar.querySelector('[data-bn-ann-dismiss]');
|
||||||
|
if(btn)btn.addEventListener('click',function(){bar.style.display='none';try{localStorage.setItem(key,'1');}catch(e){}});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>{% endif %}
|
||||||
16
templates/overrides/noir/audio-embed.ninjatpl
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{# noir override — audio-embed; preserves url, media, artwork, title, artist, download, data-bn-audio, data-audio-url, data-audio-media, {% img %} call, and the facade IIFE verbatim. Only the player card restyled (square evidence panel). #}
|
||||||
|
<figure data-block-override="noir:audio-embed" class="bn-audio noir-file my-8 overflow-hidden{% if class %} {{ class }}{% endif %}" data-bn-audio data-audio-url="{{ url|default:"" }}" data-audio-media="{% if media %}{{ media|mediaURL }}{% endif %}"><div class="flex items-center gap-4 p-4">{% if artwork %}<div class="noir-hairline h-16 w-16 shrink-0 overflow-hidden bg-muted grayscale">{% img artwork alt=title|default:"" class="h-full w-full object-cover" %}</div>{% endif %}<div class="min-w-0 flex-1">{% if title %}<p class="noir-heading truncate text-sm" style="color:hsl(var(--foreground));">{{ title }}</p>{% endif %}{% if artist %}<p class="noir-body truncate text-sm" style="color:hsl(var(--muted-foreground));">{{ artist }}</p>{% endif %}<div class="mt-2 flex items-center gap-3" data-bn-audio-slot><button type="button" data-bn-audio-play class="noir-btn noir-btn-solid text-sm" aria-label="Play{% if title %}: {{ title }}{% endif %}"><svg width="16" height="16" aria-hidden="true"><use href="/icons/lucide.svg#play"></use></svg> Play</button>{% if download %}<a href="{% if media %}{{ media|mediaURL }}{% else %}{{ url }}{% endif %}" download class="noir-link inline-flex items-center gap-1 text-sm"><svg width="16" height="16" aria-hidden="true"><use href="/icons/lucide.svg#download"></use></svg> Download</a>{% endif %}</div></div></div></figure><script>
|
||||||
|
(function(){
|
||||||
|
if(window.__bnAudioFacade)return;window.__bnAudioFacade=true;
|
||||||
|
document.addEventListener('click',function(e){
|
||||||
|
var btn=e.target.closest('[data-bn-audio-play]');if(!btn)return;
|
||||||
|
var fig=btn.closest('[data-bn-audio]');if(!fig)return;
|
||||||
|
e.preventDefault();
|
||||||
|
var src=fig.getAttribute('data-audio-media')||fig.getAttribute('data-audio-url')||'';
|
||||||
|
if(!src)return;
|
||||||
|
var slot=btn.closest('[data-bn-audio-slot]');
|
||||||
|
var a=document.createElement('audio');a.src=src;a.controls=true;a.autoplay=true;a.preload='none';a.style.cssText='width:100%;min-width:12rem;height:2.25rem;';
|
||||||
|
btn.remove();slot.insertBefore(a,slot.firstChild);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
33
templates/overrides/noir/auth-form.ninjatpl
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{# noir override — auth-form; preserves context.isPublicLoggedIn/publicUsername, #auth-message, tab onclick toggles, #login-panel/#register-panel, /api/auth/login + /api/auth/register HTMX wiring, _captcha_widget, hx-disabled-elt, forgot-password link, default_tab. #}
|
||||||
|
{% if context.isPublicLoggedIn %}
|
||||||
|
<div data-block-override="noir:auth-form" class="auth-form-logged-in mx-auto max-w-md text-center py-8">
|
||||||
|
<p class="noir-body" style="color:hsl(var(--muted-foreground));">You are signed in as <strong style="color:hsl(var(--foreground));">{{ context.publicUsername }}</strong></p>
|
||||||
|
<a href="/account" class="noir-link mt-3 inline-block">My account</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div data-block-override="noir:auth-form" class="auth-form noir-file mx-auto max-w-md bg-card p-8">
|
||||||
|
<div id="auth-message"></div>
|
||||||
|
<div class="mb-6 flex border-b border-border">
|
||||||
|
<button type="button" onclick="document.getElementById('login-panel').classList.remove('hidden');document.getElementById('register-panel').classList.add('hidden');this.setAttribute('data-active','1');this.nextElementSibling.removeAttribute('data-active');" data-auth-tab {% if default_tab != "register" %}data-active="1"{% endif %} class="noir-label flex-1 py-2 text-center border-b-2 border-transparent data-[active]:border-[hsl(var(--primary))] data-[active]:text-primary">Sign in</button>
|
||||||
|
<button type="button" onclick="document.getElementById('register-panel').classList.remove('hidden');document.getElementById('login-panel').classList.add('hidden');this.setAttribute('data-active','1');this.previousElementSibling.removeAttribute('data-active');" data-auth-tab {% if default_tab == "register" %}data-active="1"{% endif %} class="noir-label flex-1 py-2 text-center border-b-2 border-transparent data-[active]:border-[hsl(var(--primary))] data-[active]:text-primary">Register</button>
|
||||||
|
</div>
|
||||||
|
<div id="login-panel" class="{% if default_tab == "register" %}hidden{% endif %}">
|
||||||
|
<form hx-post="/api/auth/login" hx-target="#auth-message" hx-swap="innerHTML">
|
||||||
|
<div class="mb-3"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Email</label><input type="email" name="email" required class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
|
||||||
|
<div class="mb-4"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Password</label><input type="password" name="password" required class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
|
||||||
|
{% if _captcha_widget %}{{ _captcha_widget|safe }}{% endif %}
|
||||||
|
<button type="submit" class="noir-btn noir-btn-solid w-full" hx-disabled-elt="this">Sign in</button>
|
||||||
|
<p class="text-center mt-3 text-sm"><a href="/forgot-password" class="noir-link">Forgot password?</a></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="register-panel" class="{% if default_tab != "register" %}hidden{% endif %}">
|
||||||
|
<form hx-post="/api/auth/register" hx-target="#auth-message" hx-swap="innerHTML">
|
||||||
|
<div class="mb-3"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Username</label><input type="text" name="username" required pattern="[a-zA-Z0-9_-]{3,30}" class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
|
||||||
|
<div class="mb-3"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Email</label><input type="email" name="email" required class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
|
||||||
|
<div class="mb-4"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Password</label><input type="password" name="password" required minlength="8" class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
|
||||||
|
{% if _captcha_widget %}{{ _captcha_widget|safe }}{% endif %}
|
||||||
|
<button type="submit" class="noir-btn noir-btn-solid w-full" hx-disabled-elt="this">Create account</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
31
templates/overrides/noir/auth-status.ninjatpl
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{# noir override — auth-status; preserves context.isPublicLoggedIn/publicDisplayName/publicUsername, dropdown onclick, #auth-dropdown-menu, /api/auth/logout, outside-click <script> verbatim. #}
|
||||||
|
{% if context.isPublicLoggedIn %}
|
||||||
|
<div data-block-override="noir:auth-status" class="auth-status relative">
|
||||||
|
<button type="button" onclick="this.nextElementSibling.classList.toggle('hidden')" class="noir-label flex items-center gap-2 opacity-90 hover:opacity-100 px-3 py-1.5 transition-colors">
|
||||||
|
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full text-[0.7rem] font-bold" style="background-color:hsl(var(--primary));color:hsl(var(--primary-foreground));">{{ context.publicDisplayName|default:context.publicUsername|first|upper }}</span>
|
||||||
|
<span>{{ context.publicDisplayName|default:context.publicUsername }}</span>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="auth-dropdown-menu" class="hidden absolute right-0 top-full mt-1 w-48 noir-file bg-card py-1 z-50">
|
||||||
|
<a href="/account" class="block px-4 py-2 text-sm text-foreground hover:bg-accent">My profile</a>
|
||||||
|
<a href="/account/reviews" class="block px-4 py-2 text-sm text-foreground hover:bg-accent">My reviews</a>
|
||||||
|
<hr class="my-1 noir-rule"/>
|
||||||
|
<button hx-post="/api/auth/logout" hx-swap="none" class="block w-full text-left px-4 py-2 text-sm text-destructive hover:bg-accent">Sign out</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var menu = document.getElementById('auth-dropdown-menu');
|
||||||
|
var btn = document.querySelector('.auth-status > button');
|
||||||
|
if (!menu || !btn) return;
|
||||||
|
if (!btn.contains(e.target) && !menu.contains(e.target)) { menu.classList.add('hidden'); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div data-block-override="noir:auth-status" class="auth-status flex items-center gap-2">
|
||||||
|
<a href="/login" class="noir-label px-3 py-1.5 opacity-80 hover:opacity-100 hover:text-primary transition-colors">Sign in</a>
|
||||||
|
<a href="/login?tab=register" class="noir-btn noir-btn-solid text-xs">Register</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
21
templates/overrides/noir/author-bio-hero.ninjatpl
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{# noir override — author-bio-hero; preserves author.* fields, showAvatar/showPostCount/showSocial, {% img %}, all social links (website/twitter/linkedin/github). #}
|
||||||
|
{% if author %}
|
||||||
|
<section data-block-override="noir:author-bio-hero" class="border-b border-border bg-background py-12 text-foreground md:py-16{% if class %} {{ class }}{% endif %}">
|
||||||
|
<div class="mx-auto flex max-w-3xl flex-col items-center px-4 text-center">
|
||||||
|
{% if showAvatar and author.avatar_url %}<div class="mb-6 h-24 w-24 overflow-hidden rounded-full bg-muted grayscale ring-2 ring-[hsl(var(--primary))]">{% img author.avatar_url alt=author.display_name class="h-full w-full object-cover" %}</div>{% endif %}
|
||||||
|
<h1 class="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ author.display_name }}</h1>
|
||||||
|
{% if author.job_title or author.company %}<p class="noir-label mt-2" style="color:hsl(var(--primary));">{{ author.job_title }}{% if author.job_title and author.company %} · {% endif %}{{ author.company }}</p>{% endif %}
|
||||||
|
{% if author.bio %}<p class="noir-body mt-5 max-w-2xl leading-relaxed text-muted-foreground">{{ author.bio }}</p>{% endif %}
|
||||||
|
<div class="noir-label mt-6 flex flex-wrap items-center justify-center gap-4 text-muted-foreground">
|
||||||
|
{% if showPostCount %}<span>{{ author.post_count|default:0 }} post{% if author.post_count != 1 %}s{% endif %}</span>{% endif %}
|
||||||
|
{% if author.location %}<span>{{ author.location }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if showSocial %}<div class="mt-6 flex items-center justify-center gap-4">
|
||||||
|
{% if author.website_url %}<a href="{{ author.website_url }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="Website"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#globe"></use></svg></a>{% endif %}
|
||||||
|
{% if author.twitter_handle %}<a href="https://twitter.com/{{ author.twitter_handle|cut:"@" }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="Twitter"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#x"></use></svg></a>{% endif %}
|
||||||
|
{% if author.linkedin_url %}<a href="{{ author.linkedin_url }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="LinkedIn"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#linkedin"></use></svg></a>{% endif %}
|
||||||
|
{% if author.github_url %}<a href="{{ author.github_url }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="GitHub"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#github"></use></svg></a>{% endif %}
|
||||||
|
</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
14
templates/overrides/noir/author-bio.ninjatpl
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{# noir override — author-bio; preserves post.author_name/author_slug/author_avatar/author_bio, heading/showAvatar, {% img %}. #}
|
||||||
|
{% if post.author_name %}
|
||||||
|
<aside data-block-override="noir:author-bio" class="noir-file bg-card p-6 text-card-foreground{% if class %} {{ class }}{% endif %}">
|
||||||
|
{% if heading %}<p class="noir-label mb-4" style="color:hsl(var(--primary));">{{ heading }}</p>{% endif %}
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{% if showAvatar and post.author_avatar %}<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-full bg-muted grayscale ring-1 ring-[hsl(var(--primary)/0.5)]">{% img post.author_avatar alt=post.author_name class="h-full w-full object-cover" %}</div>{% endif %}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="noir-heading text-base" style="color:hsl(var(--card-foreground));">{% if post.author_slug %}<a href="/author/{{ post.author_slug }}" class="transition-colors hover:text-primary">{{ post.author_name }}</a>{% else %}{{ post.author_name }}{% endif %}</h3>
|
||||||
|
<span class="noir-rule mt-2 block w-10"></span>
|
||||||
|
{% if post.author_bio %}<p class="noir-body mt-2 text-sm text-muted-foreground">{{ post.author_bio }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
24
templates/overrides/noir/blog-hero.ninjatpl
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{# noir override — blog-hero; preserves title, subtitle, posts provider loop, post.featured_image_url/.url/.title/.excerpt/.author_name/.published_at, showExcerpt, forloop.First. {% img %} kept verbatim. #}
|
||||||
|
<section data-block-override="noir:blog-hero" class="bg-background py-12 text-foreground md:py-16{% if class %} {{ class }}{% endif %}">
|
||||||
|
<div class="mx-auto max-w-6xl px-4">
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
{% if title %}<h1 class="noir-heading text-4xl md:text-5xl" style="color:hsl(var(--foreground));">{{ title }}</h1>{% endif %}
|
||||||
|
{% if subtitle %}<p class="noir-body mt-3 text-lg" style="color:hsl(var(--muted-foreground));">{{ subtitle }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if posts %}
|
||||||
|
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
{% for post in posts %}
|
||||||
|
<article class="group flex flex-col{% if forloop.First %} md:col-span-3 md:flex-row md:items-stretch md:gap-6{% endif %}">
|
||||||
|
{% if post.featured_image_url %}<a href="{{ post.url }}" class="mb-3 block overflow-hidden bg-muted grayscale{% if forloop.First %} md:mb-0 md:w-1/2{% endif %}">{% img post.featured_image_url alt=post.title class="aspect-video h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
|
||||||
|
<div class="flex flex-col justify-center{% if forloop.First %} md:w-1/2{% endif %}">
|
||||||
|
<span class="noir-rule mb-4 block w-12"></span>
|
||||||
|
<h2 class="noir-heading {% if forloop.First %}text-2xl{% else %}text-lg{% endif %} leading-snug" style="color:hsl(var(--foreground));"><a href="{{ post.url }}" class="transition-colors hover:text-primary">{{ post.title }}</a></h2>
|
||||||
|
{% if showExcerpt and post.excerpt %}<p class="noir-body mt-2 text-sm" style="color:hsl(var(--muted-foreground));">{{ post.excerpt }}</p>{% endif %}
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-x-3 noir-label">{% if post.author_name %}<span style="color:hsl(var(--primary));">{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span style="color:hsl(var(--muted-foreground));">{{ post.published_at|date:"M j, Y" }}</span>{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
13
templates/overrides/noir/breadcrumbs.ninjatpl
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{# noir override — breadcrumbs; preserves breadcrumbs provider loop, showHome/homeLabel/separator/template/showCurrent, item.is_current/url/label. #}
|
||||||
|
{% set arrows = template == "arrows" %}
|
||||||
|
<nav aria-label="Breadcrumb" data-block-override="noir:breadcrumbs" class="py-3{% if class %} {{ class }}{% endif %}">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1">
|
||||||
|
{% if showHome %}<li class="flex items-center"><a href="/" class="noir-label text-muted-foreground transition-colors hover:text-primary">{{ homeLabel|default:"Home" }}</a></li>{% endif %}
|
||||||
|
{% for item in breadcrumbs %}
|
||||||
|
<li class="flex items-center">
|
||||||
|
{% if showHome or not forloop.First %}{% if arrows %}<svg class="mx-2 h-3.5 w-3.5" style="color:hsl(var(--primary));" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>{% else %}<span class="mx-2" style="color:hsl(var(--primary));">{{ separator|default:"/" }}</span>{% endif %}{% endif %}
|
||||||
|
{% if item.is_current %}{% if showCurrent %}<span class="noir-label text-foreground" aria-current="page">{{ item.label }}</span>{% endif %}{% else %}<a href="{{ item.url }}" class="noir-label text-muted-foreground transition-colors hover:text-primary">{{ item.label }}</a>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
12
templates/overrides/noir/button.ninjatpl
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{# button override — square case-file button. Fields (new builtin contract, OVERRIDE-CONTRACT §4): link, style, label, size, icon, iconPosition, newTab, align. The builtin {% button %} tag is replaced by a styled <a> so the noir square-outline look applies. #}
|
||||||
|
{% with align=align|default:"left" %}
|
||||||
|
<div data-block-override="noir:button" class="bn-button flex {% if align == "center" %}justify-center{% elif align == "right" %}justify-end{% else %}justify-start{% endif %}{% if class %} {{ class }}{% endif %}">
|
||||||
|
{% if style == "ghost" or style == "link" %}
|
||||||
|
<a href="{{ link|default:'#' }}"{% if newTab %} target="_blank" rel="noopener"{% endif %} class="noir-link inline-flex items-center gap-2 text-sm">{% if icon and iconPosition != "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}{{ label|default:"Learn more" }}{% if icon and iconPosition == "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</a>
|
||||||
|
{% elif style == "secondary" or style == "outline" %}
|
||||||
|
<a href="{{ link|default:'#' }}"{% if newTab %} target="_blank" rel="noopener"{% endif %} class="noir-btn">{% if icon and iconPosition != "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}{{ label|default:"Learn more" }}{% if icon and iconPosition == "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ link|default:'#' }}"{% if newTab %} target="_blank" rel="noopener"{% endif %} class="noir-btn noir-btn-solid">{% if icon and iconPosition != "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}{{ label|default:"Learn more" }}{% if icon and iconPosition == "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
4
templates/overrides/noir/card.ninjatpl
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{# card override — manila case-file panel. Fields mirror builtin: layout, link, media, title, text, linkLabel. Media via {% img %}. #}
|
||||||
|
{% with layout=layout|default:"vertical" %}
|
||||||
|
<article data-block-override="noir:card" class="bn-card noir-file overflow-hidden transition-colors {% if layout == "horizontal" %}flex flex-col sm:flex-row{% endif %}{% if class %} {{ class }}{% endif %}">{% if link %}<a href="{{ link }}" class="contents">{% endif %}{% if media %}<div class="overflow-hidden bg-muted grayscale {% if layout == "horizontal" %}sm:w-2/5 sm:shrink-0{% endif %}">{% if layout == "horizontal" %}{% img media alt=title|default:"" class="h-full w-full object-cover" %}{% else %}{% img media alt=title|default:"" class="aspect-video h-full w-full object-cover" %}{% endif %}</div>{% endif %}<div class="flex flex-col gap-2 p-5 md:p-6 {% if layout == "horizontal" %}justify-center{% endif %}">{% if title %}<h3 class="noir-heading text-lg" style="color:hsl(var(--foreground));">{{ title }}</h3>{% endif %}{% if text %}<div class="noir-body text-sm leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ text|safe }}</div>{% endif %}{% if linkLabel and link %}<span class="mt-2 inline-flex items-center gap-1 noir-label" style="color:hsl(var(--primary));">{{ linkLabel }}<svg width="16" height="16" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></span>{% endif %}</div>{% if link %}</a>{% endif %}</article>
|
||||||
|
{% endwith %}
|
||||||
18
templates/overrides/noir/category-list.ninjatpl
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{# noir override — category-list; preserves categories provider loop, style/pills, heading, showCount, cat.url/.name/.post_count. #}
|
||||||
|
{% set pills = style == "pills" %}
|
||||||
|
<section data-block-override="noir:category-list" class="bg-background text-foreground{% if class %} {{ class }}{% endif %}">
|
||||||
|
{% if heading %}<h2 class="noir-label mb-5" style="color:hsl(var(--primary));">{{ heading }}</h2>{% endif %}
|
||||||
|
{% if pills %}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for cat in categories %}
|
||||||
|
<a href="{{ cat.url }}" class="noir-label inline-flex items-center gap-1.5 rounded-none border border-border bg-card px-4 py-1.5 text-card-foreground transition-colors hover:border-primary hover:text-primary">{{ cat.name }}{% if showCount %}<span class="noir-mono text-xs" style="color:hsl(var(--muted-foreground));">{{ cat.post_count }}</span>{% endif %}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for cat in categories %}
|
||||||
|
<li><a href="{{ cat.url }}" class="noir-rule flex items-center justify-between px-3 py-2.5 text-sm transition-colors hover:text-primary" style="color:hsl(var(--muted-foreground));"><span class="noir-label">{{ cat.name }}</span>{% if showCount %}<span class="noir-mono rounded-none border border-border px-2 py-0.5 text-xs" style="color:hsl(var(--muted-foreground));">{{ cat.post_count }}</span>{% endif %}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
18
templates/overrides/noir/contact-card.ninjatpl
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{# noir override — contact-card; preserves heading, description, columns, channels[].icon/.label/.value/.link. Icon <svg><use> split markup kept verbatim. #}
|
||||||
|
<section data-block-override="noir:contact-card" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
|
||||||
|
<div class="mx-auto max-w-4xl px-4">
|
||||||
|
{% if heading %}<h2 class="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
|
||||||
|
{% if description %}<p class="noir-body mt-3 max-w-2xl text-lg" style="color:hsl(var(--muted-foreground));">{{ description }}</p>{% endif %}
|
||||||
|
<div class="mt-10 grid grid-cols-1 gap-4 {% if columns == 3 %}sm:grid-cols-2 lg:grid-cols-3{% elif columns == 1 %}{% else %}sm:grid-cols-2{% endif %}">
|
||||||
|
{% for channel in channels %}
|
||||||
|
<div class="noir-file flex items-start gap-4 p-5">
|
||||||
|
{% if channel.icon %}<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center border border-border bg-primary/10 text-primary"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/{{ channel.icon|split:":"|first }}.svg#{{ channel.icon|split:":"|last }}"></use></svg></span>{% endif %}
|
||||||
|
<div class="min-w-0">
|
||||||
|
{% if channel.label %}<div class="noir-label" style="color:hsl(var(--muted-foreground));">{{ channel.label }}</div>{% endif %}
|
||||||
|
{% if channel.value %}<div class="noir-heading mt-0.5 break-words text-base" style="color:hsl(var(--card-foreground));">{% if channel.link %}<a href="{{ channel.link }}" class="transition-colors hover:text-primary">{{ channel.value }}</a>{% else %}{{ channel.value }}{% endif %}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||