Przejdź do treści
Wszystkie przykłady

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
components/settings/settings.tsx
"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>
  )
}
components/settings/destructive-confirm.tsx
"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&apos;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>
  )
}
components/settings/settings-form.tsx
"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>
  )
}
app/settings/page.tsx
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>
  )
}