Przejdź do treści
Wszystkie przykłady

Pulpit

Nawigacja, która na telefonie wysuwa się z boku, przełącznik środowisk, wykres ruchu dla trzech okresów, przeszukiwana lista wdrożeń i puste stany dla sekcji, które dopiero powstaną.

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/dashboard.json
components/dashboard/dashboard.tsx
"use client"

import * as React from "react"
import {
  ActivityIcon,
  BarChart3Icon,
  BellIcon,
  BoxIcon,
  ChevronsUpDownIcon,
  CircleXIcon,
  GitBranchIcon,
  GlobeIcon,
  LayoutGridIcon,
  MenuIcon,
  SearchIcon,
  ZapIcon,
  type LucideIcon,
} from "lucide-react"

import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"
import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Progress } from "@/components/ui/progress"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"

const traffic = {
  "24h": {
    caption: "Requests per hour, last 24 hours",
    data: [
      42, 48, 45, 52, 58, 55, 61, 67, 64, 72, 70, 78, 84, 80, 88, 94, 91, 97,
      104, 99, 108, 115, 111, 120,
    ],
  },
  "7d": {
    caption: "Requests per day, last 7 days",
    data: [77, 82, 56, 53, 79, 86, 91],
  },
  "30d": {
    caption: "Requests per day, last 30 days",
    data: [
      52, 58, 61, 60, 63, 44, 41, 59, 64, 66, 65, 69, 47, 45, 64, 70, 72, 71,
      75, 51, 49, 70, 76, 79, 77, 82, 56, 53, 79, 86,
    ],
  },
}

type Period = keyof typeof traffic

const stats = [
  {
    label: "Requests",
    value: "12.4M",
    delta: "+18.2%",
    tone: "success",
    data: [3, 4, 3.6, 5, 5.4, 6, 5.8, 7, 7.6],
  },
  {
    label: "p95 latency",
    value: "84 ms",
    delta: "−6.1%",
    tone: "success",
    data: [9, 8.6, 8.9, 8.1, 7.9, 8.2, 7.4, 7.2, 7],
  },
  {
    label: "Error rate",
    value: "0.12%",
    delta: "+0.02",
    tone: "danger",
    data: [2, 2.1, 1.8, 2.4, 2.2, 2.6, 2.3, 2.8, 2.7],
  },
  {
    label: "Uptime",
    value: "99.99%",
    delta: "30 days",
    tone: "neutral",
    data: [5, 5, 5, 5, 4.9, 5, 5, 5, 5],
  },
] as const

const toneText = {
  success: "text-success-11",
  danger: "text-danger-11",
  neutral: "text-muted-foreground",
}

const deployments = [
  {
    id: "dpl_8f2c",
    branch: "main",
    message: "Ship the new billing page",
    status: "Ready",
    author: "AL",
    time: "2m",
  },
  {
    id: "dpl_7a91",
    branch: "feat/search",
    message: "Command palette with fuzzy search",
    status: "Building",
    author: "GH",
    time: "6m",
  },
  {
    id: "dpl_6d04",
    branch: "fix/tz",
    message: "Format dates in the viewer's zone",
    status: "Ready",
    author: "KJ",
    time: "1h",
  },
  {
    id: "dpl_5be3",
    branch: "exp/edge",
    message: "Try streaming for the dashboard",
    status: "Failed",
    author: "MS",
    time: "3h",
  },
] as const

const statusBadge = {
  Ready: "success",
  Building: "warning",
  Failed: "destructive",
} as const

type NavItem = {
  label: string
  icon: LucideIcon
  /** Sections without a page in this example say what would live there. */
  title?: string
  description?: string
}

const nav = [
  { label: "Overview", icon: LayoutGridIcon },
  { label: "Deployments", icon: BoxIcon },
  {
    label: "Analytics",
    icon: BarChart3Icon,
    title: "No events yet",
    description:
      "Add the tracking snippet and page views show up here within a minute.",
  },
  {
    label: "Domains",
    icon: GlobeIcon,
    title: "No custom domains",
    description: "meridian.app already works. Add your own domain when ready.",
  },
  {
    label: "Activity",
    icon: ActivityIcon,
    title: "Audit log is off",
    description:
      "Turn it on to keep a record of deploys, rollbacks and changed settings.",
  },
] as const satisfies readonly NavItem[]

type Section = (typeof nav)[number]["label"]

const environments = ["Production", "Preview", "Development"]

function points(
  data: readonly number[],
  width: number,
  height: number,
  pad = 2
) {
  const max = Math.max(...data)
  const min = Math.min(...data)
  return data.map((value, i) => [
    (i / (data.length - 1)) * width,
    pad + (1 - (value - min) / (max - min || 1)) * (height - pad * 2),
  ])
}

const path = (pts: number[][]) =>
  pts
    .map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`)
    .join(" ")

function Sparkline({
  data,
  className,
}: {
  data: readonly number[]
  className?: string
}) {
  return (
    <svg
      viewBox="0 0 96 28"
      className={cn("h-7 w-24 overflow-visible", className)}
      aria-hidden="true"
    >
      <path
        d={path(points(data, 96, 28))}
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinejoin="round"
        strokeLinecap="round"
      />
    </svg>
  )
}

function AreaChart({ data }: { data: readonly number[] }) {
  const width = 560
  const height = 150
  const pts = points(data, width, height, 8)
  const line = path(pts)
  const [lastX, lastY] = pts[pts.length - 1]
  return (
    // The chart stretches to fit, so the end marker is HTML: a circle in
    // the stretched SVG would turn into an ellipse.
    <div className="relative" aria-hidden="true">
      <svg
        viewBox={`0 0 ${width} ${height}`}
        preserveAspectRatio="none"
        className="block h-40 w-full"
      >
        {[0.25, 0.5, 0.75].map((f) => (
          <line
            key={f}
            x1="0"
            x2={width}
            y1={height * f}
            y2={height * f}
            stroke="var(--border)"
            strokeDasharray="3 4"
            vectorEffect="non-scaling-stroke"
          />
        ))}
        <path
          d={`${line} L${width} ${height} L0 ${height} Z`}
          fill="var(--brand-9)"
          fillOpacity="0.08"
        />
        <path
          d={line}
          fill="none"
          stroke="var(--brand-9)"
          strokeWidth="2"
          vectorEffect="non-scaling-stroke"
          strokeLinejoin="round"
        />
      </svg>
      <span
        className="absolute size-2.5 -translate-1/2 rounded-full bg-brand-9 ring-2 ring-card"
        style={{
          left: `${(lastX / width) * 100}%`,
          top: `${(lastY / height) * 100}%`,
        }}
      />
    </div>
  )
}

function SidebarContent({
  section,
  onSectionChange,
  environment,
  onEnvironmentChange,
}: {
  section: Section
  onSectionChange: (section: Section) => void
  environment: string
  onEnvironmentChange: (environment: string) => void
}) {
  return (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger className="flex items-center gap-2.5 rounded-lg p-1.5 text-left focus-ring transition-colors duration-fast hover:bg-accent data-popup-open:bg-accent">
          <span className="flex size-7 items-center justify-center rounded-md bg-foreground text-background">
            <ZapIcon className="size-3.5" />
          </span>
          <span className="flex flex-1 flex-col leading-tight">
            <span className="text-sm font-semibold">Meridian</span>
            <span className="text-xs text-muted-foreground">{environment}</span>
          </span>
          <ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
        </DropdownMenuTrigger>
        <DropdownMenuContent>
          <DropdownMenuGroup>
            <DropdownMenuLabel>Environment</DropdownMenuLabel>
            {/* Radio items tell assistive technology which one is chosen. */}
            <DropdownMenuRadioGroup
              value={environment}
              onValueChange={onEnvironmentChange}
            >
              {environments.map((name) => (
                <DropdownMenuRadioItem key={name} value={name} closeOnClick>
                  {name}
                </DropdownMenuRadioItem>
              ))}
            </DropdownMenuRadioGroup>
          </DropdownMenuGroup>
        </DropdownMenuContent>
      </DropdownMenu>

      <nav className="flex flex-col gap-px">
        {nav.map(({ label, icon: Icon }) => (
          <button
            key={label}
            type="button"
            aria-current={label === section ? "page" : undefined}
            onClick={() => onSectionChange(label)}
            className="flex h-8 items-center gap-2.5 rounded-lg px-2 text-sm text-muted-foreground focus-ring transition-colors duration-fast hover:bg-accent hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
          >
            <Icon className="size-4" />
            {label}
          </button>
        ))}
      </nav>

      <div className="mt-auto flex flex-col gap-3 rounded-xl border bg-card p-3 shadow-raised">
        <div className="flex flex-col gap-0.5">
          <span className="text-label-sm">Build minutes</span>
          <span className="text-xs text-muted-foreground">
            5,420 of 6,000 used
          </span>
        </div>
        <Progress value={90} size="sm" aria-label="Build minutes used" />
        <Button size="xs" variant="outline">
          Upgrade plan
        </Button>
      </div>
    </>
  )
}

function DeploymentsTable({ query }: { query: string }) {
  const needle = query.trim().toLowerCase()
  const rows = deployments.filter((row) =>
    [row.message, row.branch, row.id].some((field) =>
      field.toLowerCase().includes(needle)
    )
  )

  return (
    <Card size="sm" className="gap-0 py-0">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Deployment</TableHead>
            <TableHead className="hidden @2xl:table-cell">Branch</TableHead>
            <TableHead>Status</TableHead>
            <TableHead className="text-right">Age</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((row) => (
            <TableRow key={row.id}>
              {/* Takes the remaining width and truncates, so status and age
                  stay in view on narrow screens. */}
              <TableCell className="w-full max-w-0">
                <div className="flex min-w-0 items-center gap-3">
                  <Avatar size="xs">
                    <AvatarFallback>{row.author}</AvatarFallback>
                  </Avatar>
                  <div className="flex min-w-0 flex-col">
                    <span className="truncate font-medium">{row.message}</span>
                    <span className="font-mono text-xs text-muted-foreground">
                      {row.id}
                    </span>
                  </div>
                </div>
              </TableCell>
              <TableCell className="hidden @2xl:table-cell">
                <span className="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
                  <GitBranchIcon className="size-3.5" />
                  {row.branch}
                </span>
              </TableCell>
              <TableCell>
                <Badge variant={statusBadge[row.status]}>
                  <BadgeDot live={row.status === "Building"} />
                  {row.status}
                </Badge>
              </TableCell>
              <TableCell className="text-right text-muted-foreground">
                {row.time}
              </TableCell>
            </TableRow>
          ))}
          {rows.length === 0 && (
            <TableRow>
              <TableCell
                colSpan={4}
                className="h-24 text-center whitespace-normal text-muted-foreground"
              >
                No deployments match your search.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </Card>
  )
}

function Overview({ query }: { query: string }) {
  const [period, setPeriod] = React.useState<Period>("24h")

  return (
    <>
      <div className="grid grid-cols-2 gap-4 @5xl:grid-cols-4">
        {stats.map((stat) => (
          <div
            key={stat.label}
            className="@container flex flex-col gap-2 rounded-xl border bg-card p-4 shadow-raised"
          >
            <span className="text-xs text-muted-foreground">{stat.label}</span>
            <div className="flex items-end justify-between gap-2">
              <span className="text-2xl font-semibold tracking-tight whitespace-nowrap numeric">
                {stat.value}
              </span>
              {/* Too narrow for both: the figure stays, the trend goes. */}
              <Sparkline
                data={stat.data}
                className={cn("@max-[12rem]:hidden", toneText[stat.tone])}
              />
            </div>
            <span
              className={cn("text-xs font-medium numeric", toneText[stat.tone])}
            >
              {stat.delta}
            </span>
          </div>
        ))}
      </div>

      <div className="grid gap-4 @5xl:grid-cols-[1fr_17rem]">
        {/* In a narrow card the period switch drops below the caption. */}
        <Card size="sm" className="@container/traffic">
          <CardHeader className="@max-md/traffic:grid-cols-1!">
            <CardTitle>Traffic</CardTitle>
            <CardDescription>{traffic[period].caption}</CardDescription>
            <CardAction className="@max-md/traffic:col-start-1 @max-md/traffic:row-span-1 @max-md/traffic:row-start-3 @max-md/traffic:mt-1.5 @max-md/traffic:justify-self-start">
              <ToggleGroup
                variant="outline"
                size="sm"
                spacing={0}
                value={[period]}
                // Pressing the active period again would leave none chosen.
                onValueChange={([next]) => next && setPeriod(next as Period)}
                aria-label="Period"
              >
                {(Object.keys(traffic) as Period[]).map((key) => (
                  <ToggleGroupItem key={key} value={key} className="text-xs">
                    {key}
                  </ToggleGroupItem>
                ))}
              </ToggleGroup>
            </CardAction>
          </CardHeader>
          <CardContent>
            <AreaChart data={traffic[period].data} />
          </CardContent>
        </Card>

        <Card size="sm">
          <CardHeader>
            <CardTitle>Team</CardTitle>
            <CardDescription>4 people deployed today</CardDescription>
          </CardHeader>
          <CardContent className="flex flex-col gap-4">
            <AvatarGroup>
              {["AL", "GH", "KJ", "MS"].map((initials) => (
                <Avatar key={initials}>
                  <AvatarFallback>{initials}</AvatarFallback>
                </Avatar>
              ))}
            </AvatarGroup>
            <div className="flex flex-col gap-2.5">
              {[
                ["Ada merged", "#482", "2m"],
                ["Grace opened", "#487", "14m"],
                ["Katherine fixed", "#479", "1h"],
              ].map(([action, pr, time]) => (
                <div key={pr} className="flex items-center gap-2 text-body-xs">
                  <GitBranchIcon className="size-3.5 text-muted-foreground" />
                  <span className="text-foreground">{action}</span>
                  <span className="font-mono text-xs text-muted-foreground">
                    {pr}
                  </span>
                  <span className="ml-auto text-xs text-muted-foreground">
                    {time}
                  </span>
                </div>
              ))}
            </div>
          </CardContent>
        </Card>
      </div>

      <DeploymentsTable query={query} />
    </>
  )
}

export default function DashboardExample() {
  const [section, setSection] = React.useState<Section>("Overview")
  const [environment, setEnvironment] = React.useState("Production")
  const [query, setQuery] = React.useState("")
  const [menuOpen, setMenuOpen] = React.useState(false)
  const current: NavItem = nav.find((item) => item.label === section) ?? nav[0]

  const sidebar = (
    <SidebarContent
      section={section}
      onSectionChange={(next) => {
        setSection(next)
        setMenuOpen(false)
      }}
      environment={environment}
      onEnvironmentChange={setEnvironment}
    />
  )

  return (
    <div className="@container overflow-hidden rounded-xl border bg-background">
      <div className="flex min-h-[38rem]">
        <aside className="hidden w-56 shrink-0 flex-col gap-6 border-r bg-surface p-3 @3xl:flex">
          {sidebar}
        </aside>

        {/* Below the sidebar's breakpoint the same navigation opens in a sheet. */}
        <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
          <SheetContent
            side="left"
            className="w-64 gap-6 p-3"
            showCloseButton={false}
          >
            <SheetTitle className="sr-only">Navigation</SheetTitle>
            {sidebar}
          </SheetContent>
        </Sheet>

        <div className="flex min-w-0 flex-1 flex-col">
          <header className="flex h-14 items-center gap-2 border-b px-3 @lg:gap-3 @lg:px-5">
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Open navigation"
              onClick={() => setMenuOpen(true)}
              className="@3xl:hidden"
            >
              <MenuIcon />
            </Button>
            <h2 className="truncate text-heading-sm">{section}</h2>
            <Badge
              variant="success"
              size="sm"
              className="hidden @xl:inline-flex"
            >
              <BadgeDot />
              All systems normal
            </Badge>
            <div className="ml-auto flex items-center gap-2">
              <InputGroup className="hidden h-8 w-56 @5xl:flex">
                <InputGroupAddon>
                  <SearchIcon />
                </InputGroupAddon>
                <InputGroupInput
                  type="search"
                  placeholder="Search deployments…"
                  aria-label="Search deployments"
                  value={query}
                  onChange={(event) => {
                    setQuery(event.target.value)
                    // Results live in the deployments table.
                    if (current.title) setSection("Deployments")
                  }}
                />
              </InputGroup>
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={
                    <Button
                      variant="ghost"
                      size="icon-sm"
                      aria-label="Notifications"
                    />
                  }
                >
                  <BellIcon />
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end" className="w-72">
                  <DropdownMenuGroup>
                    <DropdownMenuLabel>Notifications</DropdownMenuLabel>
                    <DropdownMenuItem onClick={() => setSection("Deployments")}>
                      <CircleXIcon className="text-danger-11!" />
                      <span className="flex-1">Build failed on exp/edge</span>
                      <span className="text-xs text-muted-foreground">3h</span>
                    </DropdownMenuItem>
                    <DropdownMenuItem onClick={() => setSection("Overview")}>
                      <GitBranchIcon />
                      <span className="flex-1">Grace opened #487</span>
                      <span className="text-xs text-muted-foreground">14m</span>
                    </DropdownMenuItem>
                  </DropdownMenuGroup>
                </DropdownMenuContent>
              </DropdownMenu>
              <Avatar size="sm">
                <AvatarFallback>AL</AvatarFallback>
              </Avatar>
            </div>
          </header>

          <div className="flex flex-1 flex-col gap-4 bg-surface/40 p-3 @lg:p-5">
            {section === "Overview" && <Overview query={query} />}
            {section === "Deployments" && <DeploymentsTable query={query} />}
            {current.title && (
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <current.icon />
                  </EmptyMedia>
                  <EmptyTitle>{current.title}</EmptyTitle>
                  <EmptyDescription>{current.description}</EmptyDescription>
                </EmptyHeader>
              </Empty>
            )}
          </div>
        </div>
      </div>
    </div>
  )
}
app/dashboard/page.tsx
import type { Metadata } from "next"

import Dashboard from "@/components/dashboard/dashboard"

export const metadata: Metadata = {
  title: "Dashboard",
  description: "A deployment console: navigation, stats with sparklines, a traffic chart, team activity and a deployments table.",
}

export default function DashboardPage() {
  return (
    <main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
      <Dashboard />
    </main>
  )
}