Skip to contentVibraUI
Part of the Support dashboardinstalls at /support/changelog

Product changelog

Every release in one column: the version, the day it went out, the kinds of change it carried, and its lines — with a subscribe form that answers through a server action.

Open the live page

Releases come from db.releases, newest first, and are handed to Changelog as entries: the version becomes the monospaced badge, the summary the description, and each line of the release its own bullet under the tag it carries. The date is the part worth reading the code for. A release is dated, not timed — the entity stores midnight UTC — and Changelog formats what it is given in the runtime's own zone, so the stored instant would tell every reader west of Greenwich that a release shipped the day before it did. data.ts rebuilds each date at local noon of the same UTC calendar day, which prints the right day in any zone within twelve hours of UTC and keeps the machine-readable stamp inside that day; the test suite runs in America/Los_Angeles, where handing the instant over unchanged fails. Subscribing goes through a server action returning Result: there is no subscriber entity and inventing one for a demo would be a fiction with rows behind it, so the action validates the address and answers, which is the whole of the contract a consumer replaces. A refusal renders against the field it names, tied to it with aria-describedby, rather than as a banner above the form. Composes AppShell, PageHeader, Widget, Changelog, Input, Label and Button.

Preview

Install

npx shadcn@latest add @vibra/support-changelog

Needs the @vibra registry in your components.json — set it up once.

Source

app/support/changelog/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { Changelog } from "@/components/ui/changelog"
import { PageHeader } from "@/components/ui/page-header"
import { Widget } from "@/components/ui/widget"

import { signOut } from "./actions"
import { SubscribeForm } from "./components/subscribe-form"
import { currentUser, lastUpdated, releaseNotes, shellNotifications, shippedLine } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/support/nav"

/**
 * What shipped, and when. A server component: the releases are read from `db`
 * and rendered on the server, and the only island is the subscribe form, which
 * submits to an action.
 */
export default function ChangelogPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.changelog}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Changelog"
        description="Every release, what it changed, and which kind of change each line was."
        meta={lastUpdated()}
      />

      <Widget
        title="Releases"
        description="Newest first"
        footer="A release is dated, not timed: the day shown is the day it went out, in UTC."
      >
        <Changelog entries={releaseNotes()} />
      </Widget>

      <SubscribeForm shipped={shippedLine()} />
    </AppShell>
  )
}
app/support/changelog/data.ts
/**
 * What this page reads: `db.releases`, newest first, as `Changelog` entries.
 *
 * The one thing worth explaining here is the date. A release is dated, not
 * timed — the entity stores midnight UTC — and `Changelog` formats what it is
 * given in the runtime's own zone. Handed the stored instant, a reader west of
 * Greenwich would be told every release shipped the day before it did. So each
 * date is rebuilt at *local noon* of the same UTC calendar day: the printed day
 * is then the day it shipped in any zone within twelve hours of UTC, and the
 * machine-readable stamp `Changelog` writes stays inside that day too.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Member, type Release } from "@/lib/sample-data"
import { type ChangelogEntry } from "@/components/ui/changelog"

/** One release, as the page shows it. */
export type ReleaseNote = ChangelogEntry & {
  id: string
  version: string
  date: Date
  channel: Release["channel"]
}

/** The row's UTC calendar day, at noon in whatever zone the page renders in. */
function localNoonOf(utc: Date): Date {
  return new Date(utc.getUTCFullYear(), utc.getUTCMonth(), utc.getUTCDate(), 12)
}

const NOTES: ReleaseNote[] = db.releases
  .all()
  .sort((a, b) => b.date.getTime() - a.date.getTime())
  .map((release) => ({
    id: release.id,
    version: release.version,
    date: localNoonOf(release.date),
    channel: release.channel,
    title: release.title,
    description: release.summary,
    tags: release.tags,
    items: release.entries.map((entry) => entry.text),
  }))

/** Every release, newest first. */
export function releaseNotes(): ReleaseNote[] {
  return NOTES
}

/** How many releases there are, and which version is current. */
export function lastUpdated(): string {
  const newest = NOTES[0]
  return newest ? `${NOTES.length} releases · ${newest.version} is current` : "No releases yet"
}

// Fixed to UTC so the line reads the same wherever the page is rendered.
const SHIPPED = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })

/** The sentence under the subscribe form: when the last release actually went out. */
export function shippedLine(): string {
  const newest = db.releases.all().sort((a, b) => b.date.getTime() - a.date.getTime())[0]
  return newest
    ? `The last release went out on ${SHIPPED.format(newest.date)} UTC.`
    : `Nothing has shipped since ${SHIPPED.format(REFERENCE_DATE)} UTC.`
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
app/support/changelog/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { invalidInput, isForm, type Result } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"

/**
 * Puts an address on the release-notes list.
 *
 * There is no subscriber entity, and inventing one for a demo would be a
 * fiction with rows behind it; this validates the address and answers, which
 * is the whole of the contract a consumer replaces. Swap the body for a write
 * to your own list and nothing above this file moves.
 */
export async function subscribe(formData: FormData): Promise<Result<{ email: string }>> {
  if (!isForm(formData)) return invalidInput("Send the address as the form sends it.", "email")
  const raw = formData.get("email")
  const email = typeof raw === "string" ? raw.trim() : ""

  if (!isEmail(email)) {
    return {
      ok: false,
      error: { code: "invalid_input", field: "email", message: "Enter a valid email address." },
    }
  }

  return { ok: true, data: { email } }
}

/**
 * The other thing this page changes: signing out. A server action so the page
 * can stay a server component and still hand the shell something to call.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/support/changelog/components/subscribe-form.tsx
"use client"

import * as React from "react"

import { type Result } from "@/lib/sample-data"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Widget } from "@/components/ui/widget"

import { subscribe } from "../actions"

const EMAIL_ID = "changelog-subscribe-email"
const MESSAGE_ID = "changelog-subscribe-error"

/**
 * One address on the release-notes list.
 *
 * The action's answer is the only state: `useActionState` holds the pending
 * flag, the refusal and the confirmation together, so there is nothing to keep
 * in step. A refusal renders against the field it names — which is where the
 * reader is already looking — rather than as a banner somewhere above it.
 */
export function SubscribeForm({ shipped }: { shipped: string }) {
  const [email, setEmail] = React.useState("")
  const [result, formAction, pending] = React.useActionState<
    Result<{ email: string }> | null,
    FormData
  >((_previous, formData) => subscribe(formData), null)

  const error = result && !result.ok ? result.error : null

  return (
    <Widget
      data-widget="widget-support-changelog-subscribe-form"
      title="Release notes by email"
      description="One message per release, nothing else"
      footer={shipped}
    >
      {result?.ok ? (
        <p role="status" className="text-sm text-muted-foreground">
          <span className="text-foreground">{result.data.email}</span> is on the list. Every
          release note will arrive there the day it ships; every message carries a link to stop.
        </p>
      ) : (
        <form action={formAction} noValidate className="flex flex-col gap-3 sm:max-w-md">
          <div className="flex flex-col gap-1.5">
            <Label htmlFor={EMAIL_ID}>Email</Label>
            <Input
              id={EMAIL_ID}
              name="email"
              type="email"
              autoComplete="email"
              placeholder="you@example.com"
              required
              value={email}
              onChange={(event) => setEmail(event.target.value)}
              aria-invalid={error ? true : undefined}
              aria-describedby={error ? MESSAGE_ID : undefined}
            />
            {error ? (
              <p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
                {error.message}
              </p>
            ) : null}
          </div>

          <Button type="submit" disabled={pending} className="sm:self-start">
            {pending ? "Subscribing…" : "Subscribe"}
          </Button>
        </form>
      )}
    </Widget>
  )
}