Ustawienia
Linki sekcji, które podążają za przewijaniem, pola projektu, powiadomienia e-mail zapisywane od razu, wykorzystanie planu i potwierdzenie wpisaniem nazwy przed usunięciem.
Dodaj ten przykład do projektu z zainstalowanym prfct. Polecenie zapisuje poniższe pliki i instaluje komponenty, których używają.
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/settings.json"use client"
import * as React from "react"
import {
BellIcon,
CreditCardIcon,
SettingsIcon,
TriangleAlertIcon,
} from "lucide-react"
import DestructiveConfirm from "./destructive-confirm"
import SettingsForm from "./settings-form"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field"
import { Progress } from "@/components/ui/progress"
import { Switch } from "@/components/ui/switch"
const sections = [
{ id: "settings-general", label: "General", icon: SettingsIcon },
{ id: "settings-notifications", label: "Notifications", icon: BellIcon },
{ id: "settings-billing", label: "Billing", icon: CreditCardIcon },
{ id: "settings-danger", label: "Danger zone", icon: TriangleAlertIcon },
]
const notifications = [
{
id: "deployments",
title: "Deployments",
description: "When a production deployment succeeds or fails.",
on: true,
},
{
id: "comments",
title: "Comments",
description: "When someone mentions you or replies to you.",
on: true,
},
{
id: "digest",
title: "Weekly digest",
description: "A summary of usage and activity every Monday.",
on: false,
},
]
/** Follows the section nearest the top of the viewport, for the nav to highlight. */
function useActiveSection() {
const [active, setActive] = React.useState(sections[0].id)
React.useEffect(() => {
const visible = new Set<string>()
function update() {
// The last sections can't reach the band when little content follows
// them, so the end of the page belongs to the last one.
const atEnd =
window.scrollY > 0 &&
window.innerHeight + window.scrollY >=
document.documentElement.scrollHeight - 1
const current = atEnd
? sections.at(-1)
: sections.find((section) => visible.has(section.id))
if (current) setActive(current.id)
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id)
else visible.delete(entry.target.id)
}
update()
},
// A band across the upper part of the viewport, below a page header of
// up to 80px (observer margins can't read a CSS variable).
{ rootMargin: "-80px 0px -55% 0px" }
)
for (const { id } of sections) {
const element = document.getElementById(id)
if (element) observer.observe(element)
}
// Reaching the end of the page changes no intersection: the scroll says so.
window.addEventListener("scroll", update, { passive: true })
return () => {
observer.disconnect()
window.removeEventListener("scroll", update)
}
}, [])
return [active, setActive] as const
}
export default function SettingsExample() {
const [active, setActive] = useActiveSection()
function jump(event: React.MouseEvent, id: string) {
event.preventDefault()
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches
document
.getElementById(id)
?.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" })
setActive(id)
}
return (
<div className="@container">
<div className="grid grid-cols-[minmax(0,1fr)] gap-8 @3xl:grid-cols-[13rem_minmax(0,1fr)] @3xl:gap-10">
{/* Wraps on narrow screens, so every section stays in view. Sticks
below the page header: prfct.dev sets --page-header-height to its
header's height, and elsewhere it is 0. */}
<nav
aria-label="Settings"
className="flex flex-wrap gap-1 @3xl:sticky @3xl:top-[calc(var(--page-header-height,0px)+1.5rem)] @3xl:flex-col @3xl:self-start"
>
{sections.map(({ id, label, icon: Icon }) => (
<a
key={id}
href={`#${id}`}
aria-current={active === id ? "location" : undefined}
onClick={(event) => jump(event, id)}
className="flex h-9 items-center gap-2.5 rounded-lg px-3 text-sm text-muted-foreground focus-ring-inset transition-colors duration-fast hover:bg-accent hover:text-foreground aria-[current=location]:bg-accent aria-[current=location]:font-medium aria-[current=location]:text-foreground"
>
<Icon className="size-4" aria-hidden="true" />
{label}
</a>
))}
</nav>
<div className="flex max-w-2xl flex-col gap-6">
<div id="settings-general">
<SettingsForm />
</div>
<Card id="settings-notifications">
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Choose what we email you about. Changes save as you switch.
</CardDescription>
</CardHeader>
<CardContent>
<FieldSet>
<FieldLegend variant="label" className="sr-only">
Email notifications
</FieldLegend>
<FieldGroup className="gap-4">
{notifications.map((item) => (
<Field key={item.id} orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor={`notify-${item.id}`}>
{item.title}
</FieldLabel>
<FieldDescription>{item.description}</FieldDescription>
</FieldContent>
<Switch
id={`notify-${item.id}`}
defaultChecked={item.on}
/>
</Field>
))}
</FieldGroup>
</FieldSet>
</CardContent>
</Card>
<Card id="settings-billing">
<CardHeader>
<CardTitle className="flex items-center gap-2">
Plan <Badge variant="brand">Pro</Badge>
</CardTitle>
<CardDescription>Renews monthly on the 18th.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex justify-between gap-4 text-sm">
<span>Build minutes</span>
<span className="text-muted-foreground numeric">
5,420 of 6,000
</span>
</div>
<Progress value={90} aria-label="Build minutes used" />
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="outline" size="sm">
Manage billing
</Button>
<Button size="sm">Upgrade</Button>
</div>
</CardContent>
</Card>
<Card id="settings-danger" className="border-danger-6">
<CardHeader>
<CardTitle>Danger zone</CardTitle>
<CardDescription>
Irreversible actions for this project.
</CardDescription>
</CardHeader>
<CardContent>
<DestructiveConfirm />
</CardContent>
</Card>
</div>
</div>
</div>
)
}"use client"
import * as React from "react"
import { Trash2Icon } from "lucide-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
const PROJECT = "acme-web"
export default function DestructiveConfirm() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
const [deleting, setDeleting] = React.useState(false)
async function confirm() {
setDeleting(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
setDeleting(false)
setOpen(false)
setValue("")
toast.add({
title: `Deleted ${PROJECT}`,
description: "14 deployments and 3 domains were removed.",
})
}
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
// The dialog stays until the request settles: closing wouldn't stop it.
if (deleting) return
setOpen(next)
// Reopened, it asks for the name again.
if (!next) setValue("")
}}
>
{/* The solid destructive variant is kept for the button that confirms. */}
<AlertDialogTrigger
render={
<Button
variant="outline"
className="text-danger-11 hover:text-danger-11"
/>
}
>
<Trash2Icon data-icon="inline-start" />
Delete project
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia variant="destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>Delete “{PROJECT}”?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes 14 deployments and 3 domains. It can't
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<Field>
<FieldLabel htmlFor="confirm-name">
Type <span className="font-mono">{PROJECT}</span> to confirm
</FieldLabel>
<Input
id="confirm-name"
autoComplete="off"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</Field>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
Keep project
</AlertDialogCancel>
<Button
variant="destructive"
disabled={value !== PROJECT}
loading={deleting}
onClick={confirm}
>
Delete project
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
const regions = [
{ label: "Frankfurt (eu-central-1)", value: "fra" },
{ label: "Warsaw (eu-central-2)", value: "waw" },
{ label: "Virginia (us-east-1)", value: "iad" },
]
export default function SettingsForm() {
const [saving, setSaving] = React.useState(false)
async function save(event: React.FormEvent) {
event.preventDefault()
setSaving(true)
await new Promise((resolve) => setTimeout(resolve, 900))
setSaving(false)
toast.add({ title: "Settings saved", type: "success" })
}
return (
<form onSubmit={save} className="w-full max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Project settings</CardTitle>
<CardDescription>Changes apply to new deployments.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="project-name">Project name</FieldLabel>
<FieldDescription>
Used in URLs and notifications.
</FieldDescription>
</FieldContent>
<Input
id="project-name"
defaultValue="acme-web"
className="sm:max-w-64"
/>
</Field>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="project-region">Region</FieldLabel>
<FieldDescription>
Where functions run by default.
</FieldDescription>
</FieldContent>
<Select items={regions} defaultValue="fra">
<SelectTrigger
id="project-region"
className="w-full sm:max-w-64"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{regions.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<FieldSeparator />
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="preview-deploys">
Preview deployments
</FieldLabel>
<FieldDescription>
Deploy every pull request to a unique URL.
</FieldDescription>
</FieldContent>
<Switch id="preview-deploys" defaultChecked />
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="protect">Password protection</FieldLabel>
<FieldDescription>
Require a password to view previews.
</FieldDescription>
</FieldContent>
<Switch id="protect" />
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<Button type="submit" loading={saving}>
Save changes
</Button>
</CardFooter>
</Card>
</form>
)
}import type { Metadata } from "next"
import Settings from "@/components/settings/settings"
import { Toaster } from "@/components/ui/toast"
export const metadata: Metadata = {
title: "Settings",
description: "Responsive setting rows, notification toggles, plan usage and a danger zone with typed confirmation.",
}
export default function SettingsPage() {
return (
<Toaster>
<main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
<Settings />
</main>
</Toaster>
)
}