模板 · Settings
Settings
A workspace settings screen: a section rail, four groups of real controls, and a save bar that only exists once something changed.
Form density down a long page. A checkbox row, a switch row and a radio row were each spaced correctly on their own and then stacked, at which point they stop agreeing about what a row is — and the save bar has to pin itself over the last of them without covering it.
Workspace settings
General
What this workspace is called everywhere it appears — invitations, the status page, the footer of every export.
Changing it breaks existing invitation links.
Receives invoices and spend alerts.
Shown to anyone you invite, before they accept.
Notifications
Sent to accounts@longbeach.studio. Nothing here is marketing; turning all of it off is a supported choice.
Access
What someone can do the moment they accept an invitation, before anyone gets around to adjusting it.
Billing
Charged per workspace on the first of the month. Seats are not counted.
Card expires next month
由这些组件搭成
这里没有任何东西是为模板单独写的样式。只有这样它才能随着系统变化而保持诚实——带自己 CSS 的模板不再是对组件的检验,只是一张截图。
源码
'use client'
import {
Alert,
Avatar,
Badge,
Button,
Checkbox,
Field,
Input,
NavItem,
RadioGroup,
RadioGroupItem,
Select,
SelectItem,
Separator,
Switch,
Textarea,
} from '@misoto22/design'
import { Bell, CreditCard, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import { useState } from 'react'
const NOTIFICATIONS = [
{
id: 'deploys',
label: 'Deploy finished',
note: 'Every environment, whether it passed or failed.',
on: true,
},
{
id: 'budget',
label: 'Spend reaches 80% of the cap',
note: 'Once per billing cycle, to the billing contact only.',
on: true,
},
{
id: 'digest',
label: 'Weekly digest',
note: 'Monday 09:00, in the workspace time zone.',
on: false,
},
{
id: 'mentions',
label: 'Mentions in comments',
note: 'Only threads you are already part of.',
on: false,
},
]
/**
* A workspace settings screen, assembled from the set.
*
* The first of the eight application screens, and the one that puts the form
* controls under the pressure they are never checked under: a checkbox row, a
* switch row, a radio row and four labelled fields, stacked down one column
* with section rules between them. Each of those is spaced correctly on its own
* page in the gallery. Stacked, they have to agree on what a row is — the same
* label step, the same gap to the description under it, the same distance to
* the rule — and any one of them that was tuned in isolation is visible here as
* a limp in the rhythm.
*
* The save bar is the second thing it tests. It is `sticky bottom-0`, so it
* pins over the section the reader is in rather than waiting at the end of the
* page, and it only exists once something changed: a bar that is always there
* teaches people to ignore it, and a page with no bar at all leaves them
* hunting for the button that commits their edit.
*
* Every element is from the package.
*/
export function Settings() {
const [notifications, setNotifications] = useState(() =>
Object.fromEntries(NOTIFICATIONS.map((item) => [item.id, item.on])),
)
const [dirty, setDirty] = useState(false)
return (
<div className="grid min-h-[40rem] grid-cols-1 @3xl:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="hidden flex-col gap-1 border-e border-(--rule) p-3 @3xl:flex">
<div className="flex items-center gap-2.5 px-3 pb-4 pt-2">
<Avatar alt="" fallback="LB" size="sm" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-[13px] text-(--ink)">Longbeach Studio</span>
<span className="mono-meta text-(--ink-3-aa)">Team plan</span>
</div>
</div>
<NavItem href="#general" icon={SlidersHorizontal} active>
General
</NavItem>
<NavItem href="#notifications" icon={Bell}>
Notifications
</NavItem>
<NavItem href="#access" icon={ShieldCheck}>
Access
</NavItem>
<NavItem href="#billing" icon={CreditCard}>
Billing
</NavItem>
</aside>
<div className="flex min-w-0 flex-col">
<header className="flex flex-wrap items-baseline justify-between gap-3 border-b border-(--rule) px-6 py-5">
<h1 className="m-0 font-heading text-[length:var(--fs-sub)] font-normal text-(--ink)">
Workspace settings
</h1>
<span className="mono-meta text-(--ink-3-aa)">longbeach.studio</span>
</header>
<div className="flex flex-col gap-9 px-6 py-8">
<section id="general" aria-labelledby="settings-general" className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<h2
id="settings-general"
className="m-0 font-heading text-[length:var(--fs-item)] font-normal text-(--ink)"
>
General
</h2>
<p className="m-0 max-w-(--measure-record) text-[13px] leading-relaxed text-(--ink-3-aa)">
What this workspace is called everywhere it appears — invitations, the status page,
the footer of every export.
</p>
</div>
<div className="grid gap-5 @2xl:grid-cols-2">
<Field label="Workspace name" required>
<Input defaultValue="Longbeach Studio" onChange={() => setDirty(true)} />
</Field>
<Field label="Slug" hint="Changing it breaks existing invitation links.">
<Input defaultValue="longbeach" onChange={() => setDirty(true)} />
</Field>
<Field label="Time zone">
<Select
label="Time zone"
defaultValue="melbourne"
onValueChange={() => setDirty(true)}
>
<SelectItem value="melbourne">Australia/Melbourne</SelectItem>
<SelectItem value="tokyo">Asia/Tokyo</SelectItem>
<SelectItem value="london">Europe/London</SelectItem>
<SelectItem value="utc">UTC</SelectItem>
</Select>
</Field>
<Field label="Billing contact" hint="Receives invoices and spend alerts.">
<Input type="email" defaultValue="accounts@longbeach.studio" onChange={() => setDirty(true)} />
</Field>
</div>
<Field
label="Description"
hint="Shown to anyone you invite, before they accept."
className="max-w-(--w-reading)"
>
<Textarea
rows={3}
defaultValue="Photography and print work for record labels. Two studios, one darkroom, and a lot of scanning."
onChange={() => setDirty(true)}
/>
</Field>
</section>
<Separator />
<section
id="notifications"
aria-labelledby="settings-notifications"
className="flex flex-col gap-5"
>
<div className="flex flex-col gap-1.5">
<h2
id="settings-notifications"
className="m-0 font-heading text-[length:var(--fs-item)] font-normal text-(--ink)"
>
Notifications
</h2>
<p className="m-0 max-w-(--measure-record) text-[13px] leading-relaxed text-(--ink-3-aa)">
Sent to accounts@longbeach.studio. Nothing here is marketing; turning all of it off
is a supported choice.
</p>
</div>
<div className="flex flex-col divide-y divide-(--rule) rounded-(--radius) border border-(--rule)">
{NOTIFICATIONS.map((item) => (
<label
key={item.id}
className="flex cursor-pointer items-start gap-4 px-4 py-3.5"
>
<Switch
className="mt-0.5"
checked={notifications[item.id]}
onCheckedChange={(next) => {
setNotifications((previous) => ({ ...previous, [item.id]: next }))
setDirty(true)
}}
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span
className={
notifications[item.id]
? 'text-sm text-(--ink) transition-colors duration-(--duration-fast)'
: 'text-sm text-(--ink-3-aa) transition-colors duration-(--duration-fast)'
}
>
{item.label}
</span>
<span className="text-[13px] leading-relaxed text-(--ink-3-aa)">
{item.note}
</span>
</span>
</label>
))}
</div>
</section>
<Separator />
<section id="access" aria-labelledby="settings-access" className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<h2
id="settings-access"
className="m-0 font-heading text-[length:var(--fs-item)] font-normal text-(--ink)"
>
Access
</h2>
<p className="m-0 max-w-(--measure-record) text-[13px] leading-relaxed text-(--ink-3-aa)">
What someone can do the moment they accept an invitation, before anyone gets around
to adjusting it.
</p>
</div>
<RadioGroup
defaultValue="contributor"
aria-label="Default role for new members"
onValueChange={() => setDirty(true)}
>
<RadioGroupItem value="viewer">Viewer — read everything, change nothing</RadioGroupItem>
<RadioGroupItem value="contributor">
Contributor — edit drafts, but not published work
</RadioGroupItem>
<RadioGroupItem value="admin">Admin — including billing and members</RadioGroupItem>
</RadioGroup>
<div className="flex flex-col gap-3 border-s border-(--rule-2) ps-4">
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-(--ink-2)">
<Checkbox defaultChecked onCheckedChange={() => setDirty(true)} />
Require two-factor authentication
</label>
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-(--ink-2)">
<Checkbox onCheckedChange={() => setDirty(true)} />
Allow anyone with a longbeach.studio address to join without an invitation
</label>
</div>
</section>
<Separator />
<section id="billing" aria-labelledby="settings-billing" className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<h2
id="settings-billing"
className="m-0 font-heading text-[length:var(--fs-item)] font-normal text-(--ink)"
>
Billing
</h2>
<p className="m-0 max-w-(--measure-record) text-[13px] leading-relaxed text-(--ink-3-aa)">
Charged per workspace on the first of the month. Seats are not counted.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 rounded-(--radius) border border-(--rule) px-4 py-3.5">
<span className="text-sm text-(--ink)">Team</span>
<Badge tone="success">active</Badge>
<span className="mono-meta text-(--ink-3-aa)">$96 / month · renews 1 Oct</span>
<Button size="sm" variant="secondary" className="ms-auto">
Change plan
</Button>
</div>
<Alert tone="warning" title="Card expires next month">
Visa ending 4417 expires 10/26. Update it before the October invoice, or the workspace
drops to read-only rather than being deleted.
</Alert>
</section>
</div>
{/* Sticky rather than parked at the end of the page: the reader is in a
section, not at the bottom, and a commit button they have to scroll
to find is one they will forget to press. It appears only once
something changed — an always-present bar is one people stop
seeing. */}
{dirty && (
<div className="sticky bottom-0 z-(--z-sticky) mt-auto flex flex-wrap items-center gap-3 border-t border-(--rule-hard) bg-(--paper) px-6 py-3.5">
<span className="text-[13px] text-(--ink-2)">Unsaved changes</span>
<div className="ms-auto flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => setDirty(false)}>
Discard
</Button>
<Button size="sm" onClick={() => setDirty(false)}>
Save changes
</Button>
</div>
</div>
)}
</div>
</div>
)
}