Block
Settings.
A settings page with a section nav that turns into a select on phones: profile, hub site address and visibility, notification switches and a danger zone with a confirm dialog. Saving shows a toast.
components/settings/settings.tsx
"use client";
import * as React from "react";
import { BellIcon, GlobeIcon, TriangleAlertIcon, UserIcon } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Eyebrow } from "@/components/ui/eyebrow";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DangerSection, HubSection, NotificationsSection, ProfileSection } from "./sections";
const sections = [
{ id: "profile", label: "Profile", icon: UserIcon, content: ProfileSection },
{ id: "hub", label: "Hub site", icon: GlobeIcon, content: HubSection },
{ id: "notifications", label: "Notifications", icon: BellIcon, content: NotificationsSection },
{ id: "danger", label: "Danger zone", icon: TriangleAlertIcon, content: DangerSection },
] as const;
type SectionId = (typeof sections)[number]["id"];
export default function Settings() {
const [active, setActive] = React.useState<SectionId>("profile");
const [saving, setSaving] = React.useState(false);
function save(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setTimeout(() => {
setSaving(false);
toast.success("Settings saved", { description: "North hub picks up the changes on its next sync." });
}, 700);
}
return (
<div className="min-h-svh bg-background">
<div className="border-b bg-card bg-grid">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 lg:px-10">
<Eyebrow>North hub</Eyebrow>
<h1 className="mt-2 text-3xl font-medium tracking-[-0.05em] sm:text-4xl">
Settings<span className="text-primary">.</span>
</h1>
<p className="mt-2 text-sm text-muted-foreground">Your profile, your hub site and what we ping you about.</p>
</div>
</div>
<div className="mx-auto grid max-w-5xl gap-8 px-4 py-8 sm:px-6 md:grid-cols-[200px_minmax(0,1fr)] lg:gap-12 lg:px-10">
<nav aria-label="Settings sections">
<div className="md:hidden">
<Select value={active} onValueChange={(value) => setActive(value as SectionId)}>
<SelectTrigger className="w-full" aria-label="Settings section">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id}>
<s.icon /> {s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ul className="sticky top-6 hidden gap-1 md:grid">
{sections.map((s) => (
<li key={s.id}>
<button
type="button"
aria-current={active === s.id ? "page" : undefined}
onClick={() => setActive(s.id)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-r-md border-l px-3 py-2 text-left text-[13.5px] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
active === s.id
? "border-primary bg-secondary font-medium text-foreground [&>svg]:text-primary"
: "border-transparent text-muted-foreground hover:border-input hover:bg-secondary/60 hover:text-foreground",
s.id === "danger" && active !== s.id && "hover:text-destructive",
)}
>
<s.icon className="size-4 shrink-0" />
{s.label}
</button>
</li>
))}
</ul>
</nav>
<form onSubmit={save} className="min-w-0">
{sections.map((s) => (
<div key={s.id} hidden={active !== s.id} className="animate-in duration-200 fade-in-0">
<s.content />
</div>
))}
{active !== "danger" && (
<div className="mt-8 flex flex-col-reverse gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-end">
<p className="mr-auto font-mono text-[10px] tracking-[0.16em] text-muted-foreground uppercase">
Last saved 2 hours ago
</p>
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save changes"}
</Button>
</div>
)}
</form>
</div>
</div>
);
}components/settings/sections.tsx
"use client";
import * as React from "react";
import { EyeOffIcon, UploadIcon } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
function SectionHeader({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<h2 id={id} className="text-xl font-medium tracking-tight">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{children}</p>
</div>
);
}
export function ProfileSection() {
return (
<section aria-labelledby="settings-profile">
<SectionHeader id="settings-profile" title="Profile">How you show up on hub sites and guestbooks.</SectionHeader>
<FieldGroup>
<div className="flex items-center gap-4">
<Avatar className="size-16">
<AvatarFallback className="text-base">AM</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<Button type="button" variant="secondary" size="sm" className="w-fit">
<UploadIcon /> Upload skin
</Button>
<p className="text-xs text-muted-foreground">PNG, 64 by 64. We crop to the face.</p>
</div>
</div>
<Field>
<FieldLabel htmlFor="settings-name">
Display name
</FieldLabel>
<Input id="settings-name" name="name" defaultValue="Alex Marsh" autoComplete="name" />
</Field>
<Field>
<FieldLabel htmlFor="settings-bio">Bio</FieldLabel>
<Textarea
id="settings-bio"
name="bio"
rows={4}
defaultValue="Runs the north hub. Mostly quarries, a wheat farm and too many turtles."
/>
<FieldDescription>Shown on your hub site. 160 characters max.</FieldDescription>
</Field>
</FieldGroup>
</section>
);
}
const visibility = [
{ value: "public", title: "Public", text: "Listed in the hub directory. Anyone can visit." },
{ value: "unlisted", title: "Unlisted", text: "Anyone with the address can visit. Not listed." },
{ value: "private", title: "Private", text: "Only computers you approve can load it." },
];
export function HubSection() {
return (
<section aria-labelledby="settings-hub">
<SectionHeader id="settings-hub" title="Hub site">Where your site lives and who can find it.</SectionHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="settings-address">
Site address
</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>hub://</InputGroupText>
</InputGroupAddon>
<InputGroupInput id="settings-address" name="address" defaultValue="north-hub" className="font-mono text-[13px]" />
<InputGroupAddon align="inline-end">
<InputGroupText>.example.net</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>Letters, numbers and dashes. Changing it breaks old links.</FieldDescription>
</Field>
<FieldSeparator />
<FieldSet>
<FieldLegend variant="label">Visibility</FieldLegend>
<RadioGroup name="visibility" defaultValue="public" className="gap-3">
{visibility.map((v) => (
<FieldLabel key={v.value} htmlFor={`settings-vis-${v.value}`}>
<Field orientation="horizontal">
<RadioGroupItem id={`settings-vis-${v.value}`} value={v.value} />
<FieldContent>
<FieldTitle>{v.title}</FieldTitle>
<FieldDescription>{v.text}</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
</FieldGroup>
</section>
);
}
const notifications = [
{ id: "guestbook", title: "Guestbook entries", text: "Someone signs your guestbook.", on: true },
{ id: "fuel", title: "Low fuel", text: "A turtle drops under 10% fuel.", on: true },
{ id: "offline", title: "Server offline", text: "A linked server stops answering for 5 minutes.", on: true },
{ id: "digest", title: "Weekly digest", text: "Blocks mined, visitors and uptime every Monday.", on: false },
];
export function NotificationsSection() {
return (
<section aria-labelledby="settings-notifications">
<SectionHeader id="settings-notifications" title="Notifications">Email pings for things that need you.</SectionHeader>
<FieldSet>
<FieldLegend variant="label">
Email me when
</FieldLegend>
<div className="divide-y rounded-lg border bg-card">
{notifications.map((n) => (
<Field key={n.id} orientation="horizontal" className="px-4 py-4">
<FieldContent>
<FieldLabel htmlFor={`settings-notify-${n.id}`}>{n.title}</FieldLabel>
<FieldDescription>{n.text}</FieldDescription>
</FieldContent>
<Switch id={`settings-notify-${n.id}`} name={`notify-${n.id}`} defaultChecked={n.on} />
</Field>
))}
</div>
</FieldSet>
</section>
);
}
export function DangerSection() {
const [published, setPublished] = React.useState(true);
return (
<section aria-labelledby="settings-danger">
<SectionHeader id="settings-danger" title="Danger zone">Changes here take your hub site offline.</SectionHeader>
<div className="rounded-lg border border-destructive/50 bg-destructive/5">
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{published ? "Unpublish hub site" : "Hub site is unpublished"}
</p>
<p className="mt-1 text-[13px] text-muted-foreground">
{published
? "Visitors see a closed sign. Turtles keep reporting to you."
: "Nobody can visit hub://north-hub right now."}
</p>
</div>
{published ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive" className="w-full sm:w-auto">
<EyeOffIcon /> Unpublish
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unpublish north-hub?</AlertDialogTitle>
<AlertDialogDescription>
The site goes offline for every visitor and drops out of the directory. You can publish it again
later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep it live</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setPublished(false);
toast("Hub site unpublished", { description: "hub://north-hub is offline." });
}}
>
Unpublish
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
type="button"
variant="secondary"
className="w-full sm:w-auto"
onClick={() => {
setPublished(true);
toast.success("Hub site published", { description: "hub://north-hub is live again." });
}}
>
Publish again
</Button>
)}
</div>
</div>
</section>
);
}Installation#
$npx lanterncn add settingsThis copies all 2 files into components/settings and installs every component it uses. With the shadcn CLI: npx shadcn@latest add httptim/lantern-ui/settings.
Usage#
Render it from any page.
import Settings from "@/components/settings/settings";
export default function Page() {
return <Settings />;
}Toasts need the Toaster from the sonner component mounted once in your root layout.