Content templates

Full design ยท Content

Publication

Longshore, an independent publication covering Australian coastal science, working water and regional life: kelp restoration, oyster leases, shorebird counts, saltmarsh grazing, small ferry towns and the people who...

Live preview. Click around, it works.

About this template

Longshore, an independent publication covering Australian coastal science, working water and regional life: kelp restoration, oyster leases, shorebird counts, saltmarsh grazing, small ferry towns and the people who write about them. Readers land on a Home built around one lead story and a river of recent pieces by topic, open an Article page with an estimated reading time and a reading progress rule across the masthead, browse Topics and Author pages, read related articles and threaded comments, search across every published piece, and can join a demo Newsletter signup that never overwrites a returning subscriber's own details. A visible Viewing as Editor control switches to the newsroom side: a Drafts list, a real Article editor with a live preview pane, a scheduling field that holds a piece until its publish time arrives, and a Comment moderation queue that approves, rejects or removes a comment, each backed by a rate limited, ownership checked function so no comment can be forged as someone else's or pushed through past a limit.

What the agents are asked

The first message sent when you use this template. You can edit the plan before anything is built.

Adapt Longshore to my publication. Keep both sides and every working rule: the reader side (Home with one lead story and a river of recent articles by topic, Article page with estimated reading time, a reading progress rule and related articles, Topics, Author pages, Search across every published piece, threaded comments, and a demo Newsletter signup) and the newsroom side (Drafts, an Article editor with a live preview pane and a scheduling field, and a Comment moderation queue). Change the brand, colours, beat, topics, articles and sample authors to mine. Keep the sample content marked Sample and removable in one step, keep every article free of a real person, company or government scheme stated as fact, and keep a general information line wherever a piece touches a live policy or scientific claim. When I want real accounts, wire in sign in and the tables and functions in schema.sql as the README describes, with every secret key on the server.

An independent publication covering Australian coastal science, working water and regional life along the Bittern Coast, a fictional stretch of coastline centred on the town of Cutwater. Readers get a lead story, a river of recent pieces by topic, author pages, search, reading progress and an estimated reading time on every article, related articles, comments and a newsletter signup. A newsroom side schedules and moderates in the open, end to end.

Built on the NovaBuild template kit (docs/guardrails/TEMPLATE-KIT.md): one CONFIG block, one design token block, and a shared data layer that runs in demo mode with no backend or against a real Supabase project once one is connected, with no code change either way.

Screens

Reader side:

  • Home (#/): a masthead strap line, a lead story, and a river of recent articles.
  • Article (#/article/<slug>): a thin reading progress rule across the top of the page that draws itself in once on load then tracks scroll, an estimated reading time, the article's own photograph where it has one, related articles from the same topic, and comments.
  • Topics (#/topics) and Topic (#/topic/<slug>): every topic, and every visible article in one.
  • Author (#/author/<slug>): a portrait or initials, a bio, and that author's published work.
  • Search (#/search): a live filter across every visible article's title, dek and body.
  • A newsletter signup on Home, every article and the footer, clearly labelled as a demo.

Newsroom side ("Viewing as: Editor"):

  • Drafts (#/newsroom): every article regardless of status, a status chip, and an inline schedule or publish now action.
  • Editor (#/newsroom/new, #/newsroom/edit/<id>): title, dek, topic, byline, an optional photograph and a body field, with a live preview pane that updates as you type. Save draft, schedule for a future time, publish immediately, or delete.
  • Comments (#/newsroom/comments): every comment on every article, filterable by status, approve, reject or remove.

In this demo, "Viewing as: Editor" is a preview toggle (localStorage, not a real sign in), the same simplification learning-platform's "Viewing as: Instructor" uses. Wire in real sign in once you connect a backend, and this toggle can be replaced with whatever an is_newsroom() check on the signed in account actually returns (schema.sql). Becoming staff is never self service, in either driver: add_newsroom_member() (an existing member invites an email) and claim_newsroom_invite() (the invited account links it to itself, matched against its own verified email) are the only way an authors row is created or linked to a signed in user; see "Going live" for the real flow.

Signature: scheduling, and comments, end to end

An article can be saved as a draft, scheduled for a future time, or published immediately, from the same editor screen. Scheduling does not depend on anyone's browser being open when the time arrives: schema.sql's articles_read policy already treats a scheduled article as publicly visible the moment scheduled_for passes, whatever its stored status says, and script.js's isArticleVisible() makes the identical timestamp comparison for every reader screen in demo mode. promoteDueArticles() additionally flips the stored status to published so the Drafts screen stays tidy and "the edition turns" moment plays: it runs on a plain setInterval tick and again on visibilitychange/focus, never on requestAnimationFrame or an IntersectionObserver, because both of those stop running in a hidden tab (docs/ops/LESSONS.md L22) and a timer does not. It only ever runs for a signed in newsroom member: an ordinary reader never needs the write (the read policy above already makes the article visible on time without it), a real backend refuses it from anyone else regardless, and every attempt is awaited with its rejection caught, so a reader's tab never shows an unhandled error from a background timer they cannot see.

Comments run through submit_comment, which rate limits (no more than one comment every sixty seconds from the same email, across any article), refuses a comment on a draft or an article not open yet, and only ever inserts: there is no path, anywhere in this template, that updates an existing comment by anything a visitor could type, so nobody can edit someone else's comment by guessing their email (L35). Moderation runs through moderate_comment, gated on is_newsroom(), which approves, rejects or removes a comment regardless of which staff member wrote the article it is on, because this is one small newsroom sharing a queue rather than a marketplace of separate vendors.

Data model (schema.sql)

authors, topics, articles, comments, newsletter_subscribers. Row level security on every table. Articles and topics are readable by anyone once visible and writable by any signed in newsroom member (is_newsroom(): the signed in user owns an authors row). There is no insert policy on authors at all: an early version let any signed in reader insert their own row and become staff, which was proved live against the real database (an ordinary reader removed a stranger's comment through moderate_comment). The only way in now is add_newsroom_member() (a member invites an email, which creates an unclaimed row) followed by claim_newsroom_invite() (the invited account links it to itself, checked against its own verified auth.users email, never a value the caller types). A comment's email address is never selectable through a direct table read by anyone, staff included: it is exposed only through moderation_queue(), which checks is_newsroom() itself. Nobody may insert into comments or newsletter_subscribers directly; every public write runs through submit_comment or signup_newsletter (L25), each a SECURITY DEFINER function that repeats its own checks rather than trusting the row level security policy alone. reading_minutes is set by a trigger from the article's own body every time it is written, so the figure shown to a reader can never drift from what the article actually says.

signup_newsletter never overwrites a returning subscriber's stored name: it upserts on email with on conflict (email) do nothing, so a stranger who knows someone else's address and resubmits a different name changes nothing for the row already there (L35). It also never returns the row: an early version did, which let anyone call it with a guessed email and read back the name stored against it, proved live (it returned "J. Hartnell" for j.hartnell@example.com). It now always answers { ok: true }, the same shape for a new or an already subscribed address, so calling it twice with the same email reveals nothing either way, only that the call succeeded.

Sample data

Every author, topic, article, comment and newsletter subscriber is marked is_sample: true and carries SAMPLE_VERSION (script.js). A returning visitor's own comments and newsletter signups are never sample rows, so they always survive a version bump; only the sample cast gets replaced. The sample set includes one scheduled article (about two days out) and one genuine draft, so the Drafts screen and the scheduling moment have something to show without waiting. There is no single "remove sample data" button in this build; the pattern for one is the same used elsewhere in NovaBuild templates: iterate each table's is_sample rows and call store.remove.

Going live

  1. Run schema.sql on a Supabase project (rehearsed first with tools/rehearse-template-schema.mjs, never applied directly).
  2. Set CONFIG.backend in script.js to { mode: 'supabase', url: '<project url>', anonKey: '<anon key>' }. The anon key is public by design; row level security is the actual boundary. Never put a service_role or sb_secret_... key here; the kit refuses to start with one and the checker refuses to publish a template that contains one.
  3. Seed the first editor by hand, once: sign up a real account, then, connected as service_role (never from the browser), either update public.authors set owner_user_id = '<their auth.users id>' where slug = 'ottilie-renard' to repoint a seeded row, or call select public.add_newsroom_member('their@email', 'Their Name') from any account that already passes is_newsroom() (chicken and egg for the very first one, hence the direct update). From then on the first editor invites everyone else: add_newsroom_member(email, name) creates the row, and the invited person calls claim_newsroom_invite() once signed in with that exact email to link it to their own account. This template's UI does not offer an "invite a colleague" form yet, so both calls are made directly (the Supabase SQL editor, or a one line script) rather than through a screen.
  4. For scheduling to work with nobody's browser open at all, add a scheduled job (Supabase's own scheduler, or any external one) that calls a service_role version of the promote step on a short interval; the read policy already makes a due article visible without it, this step only keeps status itself tidy server side.
  5. Everything the customer would change (brand, contact details, locale, which screens are on) comes from CONFIG at the top of script.js. Colours, fonts, radii and spacing come from the nova:tokens block at the top of styles.css.

What is not built yet

  • An "invite a colleague" screen in the newsroom UI: add_newsroom_member() and claim_newsroom_invite() already exist and are demo mirrored (see "Going live" above), the template's UI just does not offer the form yet.
  • A real scheduled server job for the status flip described above; the public read policy makes scheduling work correctly without one, this would only tidy the stored status without anyone's browser open.
  • A single "remove sample data" action (see "Sample data" above for the pattern).

Honesty

Longshore and every person, place, article and comment in it are fictional, for demonstration only. The Bittern Coast, Cutwater, Long Point and every organisation named in the sample articles (the oyster growers, the local history group, the shire) are invented; nothing here describes a real place, council, company or government scheme as fact. This template was not modelled on any specific existing publication.

Art direction

One shoot, one grade: cool, overcast maritime light, real unretouched skin and materials, tight or asymmetric crops with negative space, hands shown at work on tools and rope rather than on a person, no logos, no readable text or lettering in any frame. Two photographs were regenerated after an earlier pass grew fake lettering on equipment: the shorebird count photograph now shows the flock itself feeding and lifting off the mudflat, and the ferry dock photograph is a misty view from an empty jetty with the ferry a distant silhouette; both articles' captions and alt text were written to match what the photographs actually show rather than what an earlier draft assumed they would.

The one video (kelp-loop.mp4) is a locked off, static camera shot of the same reef as kelp-return.webp, with only the kelp swaying and sediment drifting, so it reads as one continuous photograph rather than a separate piece of footage. The first encode came back at 1.3 MB, well past the 600 KB limit in TEMPLATES.md's Video section; re-encoded at width: 1120 and crf: 32 (the maximum TEMPLATES.md allows) it holds at 143 KB with no visible loss on the lead story's card size. It plays only on the lead story's hero, only above reduced motion, and is paused by wireHeroVideo() the instant it scrolls out of view or the tab is hidden.