Compare commits

..

2 Commits

Author SHA1 Message Date
shadcn
69a88f9579 Merge branch 'main' into shadcn/code-format 2026-03-15 12:48:37 +04:00
shadcn
4e8263d7a3 ci: add code-format action 2026-03-15 12:47:01 +04:00
4160 changed files with 37825 additions and 189131 deletions

View File

@@ -9,6 +9,5 @@
"WebFetch(domain:github.com)"
],
"deny": []
},
"outputStyle": "Explanatory"
}
}

View File

@@ -1,22 +0,0 @@
---
description: Keep registry base and radix trees in sync when editing shared UI
globs: apps/v4/registry/bases/**/*
alwaysApply: false
---
# Registry bases: Base UI ↔ Radix parity
`apps/v4/registry/bases/base` and `apps/v4/registry/bases/radix` are **parallel registries**. Anything that exists in both trees for the same purpose (preview blocks, mirrored examples, shared card layouts, etc.) **must stay in sync**.
## When editing
- If you change a file under **`bases/base/...`**, apply the **same behavioral and visual change** to the matching path under **`bases/radix/...`** (and the reverse).
- Only diverge where APIs differ (e.g. import paths like `@/registry/bases/base/ui/*` vs `@/registry/bases/radix/ui/*`, or Base UI vs Radix component props).
- Do **not** update only one side unless the user explicitly asks for a single-base change.
## Typical mirrored paths
- `blocks/preview/**` — preview cards and blocks
- Parallel `ui/*` components when both exist for the same component
After edits, briefly confirm both trees were updated (or state why one side is intentionally unchanged).

125
.github/workflows/code-format.yml vendored Normal file
View File

@@ -0,0 +1,125 @@
name: Code format
on:
workflow_run:
workflows: ["Code check"]
types:
- completed
permissions:
actions: read
contents: write
pull-requests: read
jobs:
format:
if: |
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
name: Run pnpm format:write
steps:
- name: Inspect failed workflow run
id: meta
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.workflow_run.pull_requests[0]
if (!pr) {
core.notice("Skipping because the failed run is not attached to a pull request.")
core.setOutput("should_run", "false")
return
}
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
})
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
per_page: 100,
})
const formatJob = jobs.jobs.find((job) => job.name === "pnpm format:check")
const sameRepo =
pullRequest.head.repo.full_name === `${context.repo.owner}/${context.repo.repo}`
const shouldRun = formatJob?.conclusion === "failure" && sameRepo
if (!formatJob) {
core.notice("Skipping because the format job could not be found in the failed run.")
} else if (formatJob.conclusion !== "failure") {
core.notice(
`Skipping because the format job concluded with "${formatJob.conclusion}".`
)
}
if (!sameRepo) {
core.notice(
`Skipping PR #${pullRequest.number} because its head branch lives in ${pullRequest.head.repo.full_name}.`
)
}
core.setOutput("head_ref", pullRequest.head.ref)
core.setOutput("should_run", shouldRun ? "true" : "false")
- name: Checkout pull request branch
if: steps.meta.outputs.should_run == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ steps.meta.outputs.head_ref }}
- name: Install Node.js
if: steps.meta.outputs.should_run == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
if: steps.meta.outputs.should_run == 'true'
uses: pnpm/action-setup@v4
with:
version: 9.0.6
run_install: false
- name: Get pnpm store directory
if: steps.meta.outputs.should_run == 'true'
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
if: steps.meta.outputs.should_run == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
if: steps.meta.outputs.should_run == 'true'
run: pnpm install
- name: Apply formatting
if: steps.meta.outputs.should_run == 'true'
run: pnpm format:write
- name: Commit formatting changes
if: steps.meta.outputs.should_run == 'true'
run: |
if git diff --quiet; then
echo "No formatting changes to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "style: apply automated formatting"
git push origin HEAD:${{ steps.meta.outputs.head_ref }}

View File

@@ -39,9 +39,6 @@ jobs:
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install

2
.gitignore vendored
View File

@@ -15,7 +15,6 @@ build
# misc
.DS_Store
.eslintcache
*.pem
# debug
@@ -44,4 +43,3 @@ tsconfig.tsbuildinfo
.notes
.playwright-mcp
shadcn-workspace
.codex-artifacts

View File

@@ -1,10 +1,8 @@
"use client"
import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
Field,
FieldContent,
@@ -15,10 +13,11 @@ import {
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
import { Input } from "@/styles/radix-nova/ui/input"
import { RadioGroup, RadioGroupItem } from "@/styles/radix-nova/ui/radio-group"
import { Switch } from "@/styles/radix-nova/ui/switch"
} from "@/examples/radix/ui/field"
import { Input } from "@/examples/radix/ui/input"
import { RadioGroup, RadioGroupItem } from "@/examples/radix/ui/radio-group"
import { Switch } from "@/examples/radix/ui/switch"
import { IconMinus, IconPlus } from "@tabler/icons-react"
export function AppearanceSettings() {
const [gpuCount, setGpuCount] = React.useState(8)

View File

@@ -1,20 +1,8 @@
"use client"
import * as React from "react"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,7 +15,18 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
} from "@/examples/radix/ui/dropdown-menu"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
export function ButtonGroupDemo() {
const [label, setLabel] = React.useState("personal")

View File

@@ -1,21 +1,20 @@
"use client"
import * as React from "react"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/radix-nova/ui/input-group"
} from "@/examples/radix/ui/input-group"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/examples/radix/ui/tooltip"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
export function ButtonGroupInputGroup() {
const [voiceEnabled, setVoiceEnabled] = React.useState(false)

View File

@@ -1,10 +1,9 @@
"use client"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
export function ButtonGroupNested() {
return (
<ButtonGroup>

View File

@@ -1,14 +1,13 @@
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
import { Separator } from "@/styles/radix-nova/ui/separator"
import { Textarea } from "@/styles/radix-nova/ui/textarea"
} from "@/examples/radix/ui/popover"
import { Separator } from "@/examples/radix/ui/separator"
import { Textarea } from "@/examples/radix/ui/textarea"
import { BotIcon, ChevronDownIcon } from "lucide-react"
export function ButtonGroupPopover() {
return (

View File

@@ -1,12 +1,10 @@
import { PlusIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/styles/radix-nova/ui/avatar"
import { Button } from "@/styles/radix-nova/ui/button"
} from "@/examples/radix/ui/avatar"
import { Button } from "@/examples/radix/ui/button"
import {
Empty,
EmptyContent,
@@ -14,7 +12,8 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/radix-nova/ui/empty"
} from "@/examples/radix/ui/empty"
import { PlusIcon } from "lucide-react"
export function EmptyAvatarGroup() {
return (

View File

@@ -1,5 +1,5 @@
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
import { Checkbox } from "@/examples/radix/ui/checkbox"
import { Field, FieldLabel } from "@/examples/radix/ui/field"
export function FieldCheckbox() {
return (

View File

@@ -1,5 +1,5 @@
import { Button } from "@/styles/radix-nova/ui/button"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Button } from "@/examples/radix/ui/button"
import { Checkbox } from "@/examples/radix/ui/checkbox"
import {
Field,
FieldDescription,
@@ -8,8 +8,8 @@ import {
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/styles/radix-nova/ui/field"
import { Input } from "@/styles/radix-nova/ui/input"
} from "@/examples/radix/ui/field"
import { Input } from "@/examples/radix/ui/input"
import {
Select,
SelectContent,
@@ -17,8 +17,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/radix-nova/ui/select"
import { Textarea } from "@/styles/radix-nova/ui/textarea"
} from "@/examples/radix/ui/select"
import { Textarea } from "@/examples/radix/ui/textarea"
export function FieldDemo() {
return (

View File

@@ -1,5 +1,5 @@
import { Card, CardContent } from "@/styles/radix-nova/ui/card"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Card, CardContent } from "@/examples/radix/ui/card"
import { Checkbox } from "@/examples/radix/ui/checkbox"
import {
Field,
FieldDescription,
@@ -8,7 +8,7 @@ import {
FieldLegend,
FieldSet,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
} from "@/examples/radix/ui/field"
const options = [
{

View File

@@ -1,13 +1,8 @@
"use client"
import { useState } from "react"
import {
Field,
FieldDescription,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
import { Slider } from "@/styles/radix-nova/ui/slider"
import { Field, FieldDescription, FieldTitle } from "@/examples/radix/ui/field"
import { Slider } from "@/examples/radix/ui/slider"
export function FieldSlider() {
const [value, setValue] = useState([200, 800])

View File

@@ -1,4 +1,4 @@
import { FieldSeparator } from "@/styles/radix-nova/ui/field"
import { FieldSeparator } from "@/examples/radix/ui/field"
import { AppearanceSettings } from "./appearance-settings"
import { ButtonGroupDemo } from "./button-group-demo"

View File

@@ -1,20 +1,19 @@
"use client"
import * as React from "react"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/radix-nova/ui/input-group"
import { Label } from "@/styles/radix-nova/ui/label"
} from "@/examples/radix/ui/input-group"
import { Label } from "@/examples/radix/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
} from "@/examples/radix/ui/popover"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
export function InputGroupButtonExample() {
const [isFavorite, setIsFavorite] = React.useState(false)

View File

@@ -1,12 +1,9 @@
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
import { ArrowUpIcon, Search } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
} from "@/examples/radix/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
@@ -14,13 +11,15 @@ import {
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/styles/radix-nova/ui/input-group"
import { Separator } from "@/styles/radix-nova/ui/separator"
} from "@/examples/radix/ui/input-group"
import { Separator } from "@/examples/radix/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/examples/radix/ui/tooltip"
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
import { ArrowUpIcon, Search } from "lucide-react"
export function InputGroupDemo() {
return (
@@ -89,7 +88,7 @@ export function InputGroupDemo() {
<InputGroupInput placeholder="@shadcn" />
<InputGroupAddon align="inline-end">
<div className="flex size-4 items-center justify-center rounded-full bg-primary text-foreground">
<IconCheck className="size-3 text-background" />
<IconCheck className="size-3 text-white" />
</div>
</InputGroupAddon>
</InputGroup>

View File

@@ -1,6 +1,4 @@
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { Button } from "@/examples/radix/ui/button"
import {
Item,
ItemActions,
@@ -8,7 +6,8 @@ import {
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/styles/radix-nova/ui/item"
} from "@/examples/radix/ui/item"
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
export function ItemDemo() {
return (

View File

@@ -1,24 +1,8 @@
"use client"
import { useMemo, useState } from "react"
import {
IconApps,
IconArrowUp,
IconAt,
IconBook,
IconCircleDashedPlus,
IconPaperclip,
IconPlus,
IconWorld,
IconX,
} from "@tabler/icons-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/styles/radix-nova/ui/avatar"
import { Badge } from "@/styles/radix-nova/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/examples/radix/ui/avatar"
import { Badge } from "@/examples/radix/ui/badge"
import {
Command,
CommandEmpty,
@@ -26,7 +10,7 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/styles/radix-nova/ui/command"
} from "@/examples/radix/ui/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -39,25 +23,36 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
} from "@/examples/radix/ui/dropdown-menu"
import { Field, FieldLabel } from "@/examples/radix/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/styles/radix-nova/ui/input-group"
} from "@/examples/radix/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
import { Switch } from "@/styles/radix-nova/ui/switch"
} from "@/examples/radix/ui/popover"
import { Switch } from "@/examples/radix/ui/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/examples/radix/ui/tooltip"
import {
IconApps,
IconArrowUp,
IconAt,
IconBook,
IconCircleDashedPlus,
IconPaperclip,
IconPlus,
IconWorld,
IconX,
} from "@tabler/icons-react"
const SAMPLE_DATA = {
mentionable: [

View File

@@ -1,5 +1,5 @@
import { Badge } from "@/styles/radix-nova/ui/badge"
import { Spinner } from "@/styles/radix-nova/ui/spinner"
import { Badge } from "@/examples/radix/ui/badge"
import { Spinner } from "@/examples/radix/ui/spinner"
export function SpinnerBadge() {
return (

View File

@@ -1,4 +1,4 @@
import { Button } from "@/styles/radix-nova/ui/button"
import { Button } from "@/examples/radix/ui/button"
import {
Empty,
EmptyContent,
@@ -6,8 +6,8 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/radix-nova/ui/empty"
import { Spinner } from "@/styles/radix-nova/ui/spinner"
} from "@/examples/radix/ui/empty"
import { Spinner } from "@/examples/radix/ui/spinner"
export function SpinnerEmpty() {
return (

View File

@@ -3,12 +3,15 @@ import Image from "next/image"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
import { ExamplesNav } from "@/components/examples-nav"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { PageNav } from "@/components/page-nav"
import { ThemeSelector } from "@/components/theme-selector"
import { Button } from "@/registry/new-york-v4/ui/button"
import { RootComponents } from "./components"
@@ -60,7 +63,11 @@ export default function IndexPage() {
</Button>
</PageActions>
</PageHeader>
<div className="container-wrapper flex-1 pb-6">
<PageNav className="hidden md:flex">
<ExamplesNav className="flex-1 overflow-hidden [&>a:first-child]:text-primary" />
<ThemeSelector className="mr-4 hidden md:flex" />
</PageNav>
<div className="container-wrapper flex-1 section-soft pb-6">
<div className="container overflow-hidden">
<section className="-mx-4 w-[160vw] overflow-hidden rounded-lg border border-border/50 md:hidden md:w-[150vw]">
<Image

View File

@@ -1,216 +0,0 @@
"use client"
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/styles/base-sera/ui/pagination"
import { Progress, ProgressValue } from "@/styles/base-sera/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
const ARTICLE_ROWS = [
{
title: "The Future of Sustainable Architecture",
wordProgress: "1.4k / 2.6k words",
author: "Elena Rostova",
issue: "Summer 2024",
status: "in-revision",
statusLabel: "In revision",
progress: 45,
},
{
title: "Brutalism's Second Act",
wordProgress: "2.1k / 2.5k words",
author: "Marcus Chen",
issue: "Summer 2024",
status: "final-edit",
statusLabel: "Final edit",
progress: 90,
},
{
title: "The Typography of Public Spaces",
wordProgress: "0.5k / 1.5k words",
author: "Sarah Jenkins",
issue: "Autumn 2024",
status: "drafting",
statusLabel: "Drafting",
progress: 20,
},
{
title: "Rethinking Urban Canopies",
wordProgress: "1.8k / 1.8k words",
author: "David O'Connor",
issue: "Summer 2024",
status: "published",
statusLabel: "Published",
progress: 100,
},
{
title: "Light, Glass, and the Modern Museum",
wordProgress: "1.2k / 2.0k words",
author: "Amara Osei",
issue: "Autumn 2024",
status: "in-revision",
statusLabel: "In revision",
progress: 55,
},
{
title: "Concrete Utopias: Housing in the 21st Century",
wordProgress: "3.0k / 3.0k words",
author: "Tomás Herrera",
issue: "Summer 2024",
status: "published",
statusLabel: "Published",
progress: 100,
},
{
title: "Designing for Silence",
wordProgress: "0.8k / 2.2k words",
author: "Ingrid Solberg",
issue: "Winter 2024",
status: "drafting",
statusLabel: "Drafting",
progress: 30,
},
{
title: "The Invisible Infrastructure of Cities",
wordProgress: "2.4k / 2.8k words",
author: "James Whitfield",
issue: "Autumn 2024",
status: "final-edit",
statusLabel: "Final edit",
progress: 85,
},
] as const
const STATUS_BADGE_VARIANT = {
"in-revision": "outline",
"final-edit": "default",
drafting: "ghost",
published: "secondary",
} as const
const STATUS_DOT_CLASSNAME = {
"in-revision": "bg-amber-600/80",
"final-edit": "bg-foreground/90",
drafting: "bg-muted-foreground/60",
published: "bg-emerald-600/80",
}
export function ArticleDirectory() {
return (
<Card>
<CardHeader>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput type="search" placeholder="Search articles..." />
</InputGroup>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Title</TableHead>
<TableHead className="w-[170px]">Author</TableHead>
<TableHead className="w-[150px]">Issue</TableHead>
<TableHead className="w-[180px]">Status</TableHead>
<TableHead className="w-[140px]">Progress</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ARTICLE_ROWS.map((row) => (
<TableRow key={row.title}>
<TableCell>
<div className="flex flex-col gap-1">
<p className="font-heading text-xl tracking-tight text-foreground">
{row.title}
</p>
<p className="text-xs text-muted-foreground">
{row.wordProgress}
</p>
</div>
</TableCell>
<TableCell>{row.author}</TableCell>
<TableCell>{row.issue}</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE_VARIANT[row.status]}>
<span
className={cn(
"size-1.5 rounded-full",
STATUS_DOT_CLASSNAME[row.status]
)}
/>
{row.statusLabel}
</Badge>
</TableCell>
<TableCell>
<Progress
value={row.progress}
aria-label={`${row.progress}% complete`}
className="flex flex-row-reverse items-center **:data-[slot=progress-track]:w-16"
>
<ProgressValue>
{(formattedValue) => `${formattedValue}`}
</ProgressValue>
</Progress>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink
href="#"
size="icon-sm"
aria-label="Previous page"
>
<ChevronLeftIcon className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
{[1, 2, 3].map((page) => (
<PaginationItem key={page}>
<PaginationLink href="#" size="icon-sm" isActive={page === 1}>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationLink href="#" size="icon-sm" aria-label="Next page">
<ChevronRightIcon className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}

View File

@@ -1,47 +0,0 @@
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList className="justify-center md:justify-start">
<BreadcrumbItem>
<BreadcrumbLink
href="#"
className="inline-flex items-center gap-1.5"
>
<ArrowLeftIcon className="size-3" />
Editorial Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Article Directory
</h1>
</div>
<div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button>
<PlusIcon data-icon="inline-start" />
New Article
</Button>
</ButtonGroup>
</div>
</div>
</header>
)
}

View File

@@ -1,16 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { ArticleDirectory as ArticleDirectoryList } from "./components/article-directory"
import { PreviewHeader } from "./components/preview-header"
export function ArticleDirectory() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<ArticleDirectoryList />
</div>
</div>
)
}

View File

@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import { MoveRightIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/styles/base-sera/ui/progress"
const DEMOGRAPHIC_DATA = [
{ age: "18 - 24", percentage: 22 },
{ age: "25 - 34", percentage: 64 },
{ age: "35 - 44", percentage: 12 },
{ age: "45+", percentage: 5 },
]
export function Demographics({ ...props }: React.ComponentProps<typeof Card>) {
return (
<Card {...props}>
<CardHeader>
<CardTitle className="text-2xl">Demographics</CardTitle>
<CardDescription>Reader Profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-10">
{DEMOGRAPHIC_DATA.map((item) => (
<Progress
key={item.age}
value={item.percentage}
aria-label={item.age}
>
<ProgressLabel>{item.age}</ProgressLabel>
<ProgressValue>
{(formattedValue) => `${formattedValue}`}
</ProgressValue>
</Progress>
))}
</CardContent>
<CardFooter>
<Button variant="link" className="w-full">
View all source <MoveRightIcon data-icon="inline-end" />
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -1,93 +0,0 @@
import { TrendingDownIcon, TrendingUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
type Metric = {
label: string
value: string
comparison: string
change: string
trend: "up" | "down"
}
const METRIC_CARDS: Metric[] = [
{
label: "Total visitors",
value: "248.5k",
comparison: "12.4%",
change: "vs last period",
trend: "up",
},
{
label: "Unique readers",
value: "182.1k",
comparison: "8.7%",
change: "vs last period",
trend: "up",
},
{
label: "Avg. time on page",
value: "3m 42s",
comparison: "1.2%",
change: "vs last period",
trend: "down",
},
{
label: "Bounce rate",
value: "42.8%",
comparison: "3.5%",
change: "vs last period",
trend: "down",
},
]
export function MetricsGrid() {
return (
<>
{METRIC_CARDS.map((metric) => (
<MetricCard
key={metric.label}
metric={metric}
className="col-span-full md:col-span-6 lg:col-span-3"
/>
))}
</>
)
}
function MetricCard({
metric,
className,
}: {
metric: Metric
className: string
}) {
return (
<Card className={cn("gap-0", className)}>
<CardContent className="flex flex-col gap-2">
<CardDescription className="text-xs uppercase">
{metric.label}
</CardDescription>
<CardTitle className="text-5xl tracking-tight lowercase">
{metric.value}
</CardTitle>
<CardDescription>
{metric.trend === "up" ? (
<TrendingUpIcon className="inline-block size-2.5 text-muted-foreground" />
) : (
<TrendingDownIcon className="inline-block size-2.5 text-muted-foreground" />
)}{" "}
<span className="text-foreground">{metric.comparison}</span>{" "}
<span>{metric.change}</span>
</CardDescription>
</CardContent>
</Card>
)
}

View File

@@ -1,103 +0,0 @@
"use client"
import * as React from "react"
import { ChevronDownIcon, DownloadIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
const EXPORT_DATE_OPTIONS = [
{
label: "Last 7 days",
value: "last-7-days",
},
{
label: "Last 30 days",
value: "last-30-days",
},
{
label: "This month",
value: "this-month",
},
{
label: "Last month",
value: "last-month",
},
]
export function PreviewHeader() {
const [selectedDateRange, setSelectedDateRange] =
React.useState("last-30-days")
const selectedDateRangeLabel = React.useMemo(() => {
const selectedOption = EXPORT_DATE_OPTIONS.find(
(option) => option.value === selectedDateRange
)
if (!selectedOption) {
return "Last 30 days"
}
return selectedOption.label
}, [selectedDateRange])
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Audience Analytics
</h1>
<div className="line-clamp-1 text-sm font-medium tracking-wider text-muted-foreground uppercase">
Editorial Performance Dashboard
</div>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
className="bg-background hover:bg-background/80 data-popup-open:bg-background"
/>
}
>
{selectedDateRangeLabel}{" "}
<ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuRadioGroup
value={selectedDateRange}
onValueChange={setSelectedDateRange}
>
{EXPORT_DATE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button>
<DownloadIcon data-icon="inline-start" />
<span className="lg:hidden">Export</span>
<span className="hidden lg:inline">Export Report</span>
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import { ArrowDownIcon, MoreHorizontalIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import { Spinner } from "@/styles/base-sera/ui/spinner"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/styles/base-sera/ui/toggle-group"
type EditorialMetric = "views" | "time" | "shares"
type EditorialRow = {
rank: number
title: string
author: string
published: string
pageviews: string
avgTime: string
}
const METRIC_LABEL: Record<EditorialMetric, string> = {
views: "VIEWS",
time: "TIME",
shares: "SHARES",
}
const EDITORIAL_ROWS: EditorialRow[] = [
{
rank: 1,
title: "The New Vanguard of Minimalist Architecture",
author: "Elena Rostova",
published: "Oct 12",
pageviews: "45.2k",
avgTime: "04:15",
},
{
rank: 2,
title: "Autumn Sartorial Code: Deconstructed Classics",
author: "Julian Vance",
published: "Oct 05",
pageviews: "38.9k",
avgTime: "03:42",
},
{
rank: 3,
title: "Interview: Director Sofia Coppola on The Aesthetics of Isolation",
author: "Marcus Trent",
published: "Sep 28",
pageviews: "31.4k",
avgTime: "06:20",
},
{
rank: 4,
title: "Sourcing Ceramics from Kyoto's Oldest Kilns",
author: "Sarah Lin",
published: "Oct 18",
pageviews: "22.1k",
avgTime: "02:55",
},
{
rank: 5,
title: "Field Notes from Copenhagen Design Week",
author: "Noah Bennett",
published: "Oct 21",
pageviews: "19.7k",
avgTime: "03:18",
},
{
rank: 6,
title: "A Studio Visit with Milan's Most Elusive Lighting Designer",
author: "Claire Duval",
published: "Oct 09",
pageviews: "17.4k",
avgTime: "04:02",
},
{
rank: 7,
title: "Collecting the New Avant-Garde in Contemporary Furniture",
author: "Tommy Rhodes",
published: "Sep 30",
pageviews: "15.9k",
avgTime: "03:36",
},
{
rank: 8,
title: "Inside Lisbon's Quiet Culinary Renaissance",
author: "Amara Iqbal",
published: "Oct 14",
pageviews: "14.2k",
avgTime: "05:08",
},
{
rank: 9,
title: "Why Slow Interiors Are Defining the Next Luxury Wave",
author: "Henry Vale",
published: "Oct 03",
pageviews: "12.7k",
avgTime: "03:11",
},
{
rank: 10,
title: "The Return of Print: Independent Magazine Covers to Watch",
author: "Mina Okafor",
published: "Sep 26",
pageviews: "11.3k",
avgTime: "02:49",
},
]
type TopEditorialProps = React.ComponentProps<typeof Card> & {
selectedMetric?: EditorialMetric
}
export function TopEditorial({
selectedMetric = "views",
...props
}: TopEditorialProps) {
const [visibleCount, setVisibleCount] = React.useState(5)
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
const hasMoreRows = visibleCount < EDITORIAL_ROWS.length
const visibleRows = EDITORIAL_ROWS.slice(0, visibleCount)
const handleLoadMore = React.useCallback(() => {
if (!hasMoreRows || isLoadingMore) {
return
}
setIsLoadingMore(true)
window.setTimeout(() => {
setVisibleCount(EDITORIAL_ROWS.length)
setIsLoadingMore(false)
}, 2000)
}, [hasMoreRows, isLoadingMore])
return (
<Card {...props}>
<CardHeader>
<div className="flex flex-col gap-(--gap) sm:flex-row">
<div className="flex flex-col gap-1.5">
<CardTitle className="text-2xl">Top Editorials</CardTitle>
<CardDescription>Ranked by engagement</CardDescription>
</div>
<ToggleGroup
aria-label="Top editorials metric selector"
value={[selectedMetric]}
variant="outline"
className="w-full sm:ml-auto sm:w-fit"
>
{(["views", "time", "shares"] as const).map((metric) => {
return (
<ToggleGroupItem key={metric} value={metric} className="flex-1">
{METRIC_LABEL[metric]}
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
</CardHeader>
<CardContent className="flex-1 **:data-[slot=table-container]:no-scrollbar **:data-[slot=table-container]:overflow-y-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Title</TableHead>
<TableHead>Published</TableHead>
<TableHead>Page Views</TableHead>
<TableHead>Read Time</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.rank}>
<TableCell className="translate-y-1 align-text-top">
{row.rank}
</TableCell>
<TableCell>
<div className="flex flex-col gap-2">
<p className="font-heading text-xl tracking-tight text-foreground">
{row.title}
</p>
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
By {row.author}
</p>
</div>
</TableCell>
<TableCell>{row.published}</TableCell>
<TableCell>{row.pageviews}</TableCell>
<TableCell>{row.avgTime}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-xs" />}
aria-label={`Open actions for ${row.title}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Publish</DropdownMenuItem>
<DropdownMenuItem variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter className="justify-center">
{hasMoreRows ? (
<Button
type="button"
variant="outline"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
Load more content{" "}
{isLoadingMore ? (
<Spinner data-icon="inline-end" />
) : (
<ArrowDownIcon data-icon="inline-end" />
)}
</Button>
) : null}
</CardFooter>
</Card>
)
}

View File

@@ -1,57 +0,0 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
const TrafficOverviewContent = dynamic(
() => import("./traffic-overview").then((mod) => mod.TrafficOverview),
{
ssr: false,
loading: () => <TrafficOverviewFallback />,
}
)
export function TrafficOverviewDeferred({
className,
...props
}: React.ComponentProps<typeof Card>) {
return (
<div className={className}>
<TrafficOverviewContent {...props} />
</div>
)
}
function TrafficOverviewFallback() {
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
<CardDescription>
Traffic for the last 30 days has increased by 12.4% compared to the
previous period.
</CardDescription>
</CardHeader>
<CardContent>
<div
aria-hidden="true"
className="flex h-82 w-full flex-col justify-end gap-6 overflow-hidden bg-muted/40 p-5"
>
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,155 +0,0 @@
"use client"
import { TrendingUpIcon } from "lucide-react"
import {
CartesianGrid,
Line,
LineChart,
ReferenceDot,
XAxis,
YAxis,
} from "recharts"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/styles/base-sera/ui/chart"
const TRAFFIC_OVERVIEW_DATA = [
{ date: "2025-10-01", views: 2600, unique: 1600 },
{ date: "2025-10-04", views: 4500, unique: 3000 },
{ date: "2025-10-08", views: 3500, unique: 2500 },
{ date: "2025-10-10", views: 6400, unique: 4500 },
{ date: "2025-10-13", views: 5400, unique: 4000 },
{ date: "2025-10-15", views: 8300, unique: 6500 },
{ date: "2025-10-17", views: 7400, unique: 6000 },
{ date: "2025-10-18", views: 9240, unique: 7105 },
{ date: "2025-10-22", views: 7700, unique: 6400 },
{ date: "2025-10-26", views: 8800, unique: 7000 },
{ date: "2025-10-29", views: 9800, unique: 8400 },
]
const TRAFFIC_CHART_CONFIG = {
views: {
label: "Views",
theme: {
light: "var(--chart-5)",
dark: "var(--chart-1)",
},
},
unique: {
label: "Unique",
theme: {
light: "var(--chart-1)",
dark: "var(--chart-2)",
},
},
} satisfies ChartConfig
const X_AXIS_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
function formatYAxisTick(value: number) {
if (value === 0) {
return "0"
}
if (value % 1000 === 0) {
return `${value / 1000}k`
}
return `${value / 1000}k`
}
function formatXAxisTick(value: string) {
const date = new Date(`${value}T00:00:00Z`)
if (Number.isNaN(date.getTime())) {
return value
}
return X_AXIS_DATE_FORMATTER.format(date)
}
export function TrafficOverview({
...props
}: React.ComponentProps<typeof Card>) {
return (
<Card {...props}>
<CardHeader>
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
<CardDescription>
Traffic for the last 30 days has increased by 12.4% compared to the
previous period.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={TRAFFIC_CHART_CONFIG} className="h-82 w-full">
<LineChart data={TRAFFIC_OVERVIEW_DATA}>
<CartesianGrid
vertical={false}
strokeDasharray="3 6"
stroke="var(--border)"
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
tickMargin={10}
tickFormatter={formatXAxisTick}
/>
<YAxis
tickLine={false}
axisLine={false}
width={44}
domain={[0, 10000]}
ticks={[0, 2500, 5000, 7500, 10000]}
tickFormatter={formatYAxisTick}
hide
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="linear"
dataKey="views"
stroke="var(--color-views)"
strokeWidth={2.2}
dot={false}
activeDot={{ r: 3.5, fill: "var(--color-views)" }}
/>
<Line
type="linear"
dataKey="unique"
stroke="var(--color-unique)"
strokeWidth={2}
strokeDasharray="4 6"
dot={false}
activeDot={false}
/>
<ReferenceDot
x="2025-10-18"
y={9240}
r={2.5}
fill="var(--color-views)"
stroke="var(--color-views)"
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -1,22 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { Demographics } from "./components/demographics"
import { MetricsGrid } from "./components/metrics-grid"
import { PreviewHeader } from "./components/preview-header"
import { TopEditorial } from "./components/top-editorial"
import { TrafficOverviewDeferred } from "./components/traffic-overview-deferred"
export function AudienceAnalytics() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-12 gap-(--gap) py-(--gap)">
<MetricsGrid />
<TrafficOverviewDeferred className="col-span-full md:col-span-6 lg:col-span-8" />
<Demographics className="col-span-full md:col-span-6 lg:col-span-4" />
<TopEditorial className="col-span-full" />
</div>
</div>
)
}

View File

@@ -1,46 +0,0 @@
import Image from "next/image"
import { cn } from "@/lib/utils"
export function ImagePreview() {
return (
<div className="mt-8 flex flex-col overflow-hidden md:hidden">
<ImagePreviewItem name="sera-01" />
<ImagePreviewItem name="sera-03" />
<ImagePreviewItem name="sera-02" />
<ImagePreviewItem name="sera-06" />
</div>
)
}
function ImagePreviewItem({
name,
className,
}: {
name: string
className?: string
}) {
return (
<div
className={cn(
"theme-taupe overflow-hidden bg-muted px-4 py-2 first:pt-4 last:pb-4",
className
)}
>
<Image
src={`/images/${name}-light.png`}
alt={name}
width={1440}
height={900}
className="dark:hidden"
/>
<Image
src={`/images/${name}-dark.png`}
alt={name}
width={1440}
height={900}
className="hidden dark:block"
/>
</div>
)
}

View File

@@ -1,148 +0,0 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
type LazyPreviewName =
| "articleDirectory"
| "emptyState"
| "editArticle"
| "mediaLibrary"
| "mediaLibraryTable"
const PREVIEW_MIN_HEIGHTS: Record<LazyPreviewName, number> = {
articleDirectory: 760,
emptyState: 560,
editArticle: 980,
mediaLibrary: 880,
mediaLibraryTable: 980,
}
const ArticleDirectoryPreview = dynamic(
() => import("../article-directory").then((mod) => mod.ArticleDirectory),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.articleDirectory} />
),
}
)
const EmptyStatePreview = dynamic(
() => import("../empty-state").then((mod) => mod.EmptyState),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.emptyState} />
),
}
)
const EditArticlePreview = dynamic(
() => import("../edit-article").then((mod) => mod.EditArticle),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.editArticle} />
),
}
)
const MediaLibraryPreview = dynamic(
() => import("../media-library").then((mod) => mod.MediaLibrary),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibrary} />
),
}
)
const MediaLibraryTablePreview = dynamic(
() => import("../media-library-table").then((mod) => mod.MediaLibraryTable),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibraryTable} />
),
}
)
const PREVIEW_COMPONENTS: Record<LazyPreviewName, React.ComponentType> = {
articleDirectory: ArticleDirectoryPreview,
emptyState: EmptyStatePreview,
editArticle: EditArticlePreview,
mediaLibrary: MediaLibraryPreview,
mediaLibraryTable: MediaLibraryTablePreview,
}
export function LazyPreview({ name }: { name: LazyPreviewName }) {
const containerRef = React.useRef<HTMLDivElement>(null)
const [shouldRender, setShouldRender] = React.useState(false)
const PreviewComponent = PREVIEW_COMPONENTS[name]
React.useEffect(() => {
if (shouldRender) {
return
}
const container = containerRef.current
if (!container || !("IntersectionObserver" in window)) {
setShouldRender(true)
return
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return
}
setShouldRender(true)
observer.disconnect()
},
{
rootMargin: "800px 0px",
}
)
observer.observe(container)
return () => observer.disconnect()
}, [shouldRender])
return (
<div ref={containerRef}>
{shouldRender ? (
<PreviewComponent />
) : (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS[name]} />
)}
</div>
)
}
function PreviewPlaceholder({ minHeight }: { minHeight: number }) {
return (
<div
aria-hidden="true"
className="preview theme-taupe @container/preview w-full flex-1 bg-muted p-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:p-6 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)]"
style={{ minHeight }}
>
<div className="container flex flex-col gap-(--gap) py-(--gap)">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-3">
<div className="h-5 w-44 bg-background/80" />
<div className="h-3 w-56 max-w-full bg-background/60" />
</div>
<div className="hidden h-8 w-28 bg-background/70 sm:block" />
</div>
<div className="grid grid-cols-1 gap-(--gap) md:grid-cols-3">
<div className="min-h-48 bg-background/70 md:col-span-2" />
<div className="min-h-48 bg-background/70" />
</div>
</div>
</div>
)
}

View File

@@ -1,59 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const THEME_OPTIONS = [
{ label: "Taupe", value: "theme-taupe" },
{ label: "Neutral", value: "theme-neutral" },
{ label: "Stone", value: "theme-stone" },
{ label: "Zinc", value: "theme-zinc" },
{ label: "Mauve", value: "theme-mauve" },
{ label: "Olive", value: "theme-olive" },
{ label: "Mist", value: "theme-mist" },
] as const
const DEFAULT_THEME = "theme-taupe"
function applyThemeToPreviews(theme: string) {
const previewElements = document.querySelectorAll<HTMLElement>(".preview")
previewElements.forEach((element) => {
THEME_OPTIONS.forEach((option) => {
element.classList.remove(option.value)
})
element.classList.add(theme)
})
}
export function ThemeSwitcher() {
const [theme, setTheme] = React.useState<string>(DEFAULT_THEME)
React.useEffect(() => {
applyThemeToPreviews(theme)
}, [theme])
return (
<div className="fixed inset-x-0 bottom-8 z-50 flex justify-center px-4">
<div className="w-full max-w-[60vw] rounded-full border-0 bg-neutral-950/50 p-1.5 shadow-xl backdrop-blur-xl sm:max-w-fit">
<div className="no-scrollbar flex snap-x snap-mandatory items-center overflow-x-auto">
{THEME_OPTIONS.map((option) => (
<button
data-active={theme === option.value}
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
}}
className="shrink-0 snap-center rounded-full px-3 py-1.5 text-sm font-medium text-neutral-300 outline-hidden transition-colors select-none hover:text-neutral-100 data-active:bg-neutral-500 data-active:text-neutral-100"
>
{option.label}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,337 +0,0 @@
"use client"
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ChevronDownIcon,
Code2Icon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImageIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
RedoIcon,
StrikethroughIcon,
TypeIcon,
UnderlineIcon,
UndoIcon,
} from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@/styles/base-sera/ui/button-group"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/styles/base-sera/ui/field"
import { Input } from "@/styles/base-sera/ui/input"
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/styles/base-sera/ui/progress"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/base-sera/ui/select"
import { Textarea } from "@/styles/base-sera/ui/textarea"
type Milestone = {
name: string
complete: boolean
note?: string
}
const MILESTONES: Milestone[] = [
{
name: "Outline & Commissioning",
complete: true,
},
{
name: "First Draft Submitted",
complete: true,
},
{
name: "Review & Revisions",
complete: false,
note: "Waiting on editor",
},
{
name: "Final Copy Edit",
complete: false,
},
{
name: "Art Direction & Layout",
complete: false,
},
]
const ISSUES = [
{ label: "Spring Issue 2024", value: "spring-2024" },
{ label: "Summer Issue 2024", value: "summer-2024" },
{ label: "Autumn Issue 2024", value: "autumn-2024" },
{ label: "Winter Issue 2024", value: "winter-2024" },
]
export function EditorWorkspace() {
return (
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_300px]">
<section className="flex flex-col border border-border/70 bg-background">
<div className="flex border-b p-2">
<ButtonGroup>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm">
Normal Text
<ChevronDownIcon data-icon="inline-end" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem>
<TypeIcon />
Normal Text
</DropdownMenuItem>
<DropdownMenuItem>
<Heading1Icon />
Heading 1
</DropdownMenuItem>
<DropdownMenuItem>
<Heading2Icon />
Heading 2
</DropdownMenuItem>
<DropdownMenuItem>
<Heading3Icon />
Heading 3
</DropdownMenuItem>
<DropdownMenuItem>
<ListIcon />
Bullet List
</DropdownMenuItem>
<DropdownMenuItem>
<ListOrderedIcon />
Numbered List
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ButtonGroupSeparator className="mx-2 data-vertical:h-4 data-vertical:self-center" />
<Button variant="ghost" size="icon-sm" aria-label="Bold">
<BoldIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Italic">
<ItalicIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Underline">
<UnderlineIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Strikethrough"
className="hidden md:flex"
>
<StrikethroughIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Code"
className="hidden md:flex"
>
<Code2Icon />
</Button>
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Left"
className="hidden md:flex"
>
<AlignLeftIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Center"
className="hidden md:flex"
>
<AlignCenterIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Right"
className="hidden md:flex"
>
<AlignRightIcon />
</Button>
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
<Button
variant="ghost"
size="icon-sm"
aria-label="Link"
className="hidden md:flex"
>
<LinkIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Image"
className="hidden md:flex"
>
<ImageIcon />
</Button>
</ButtonGroup>
<ButtonGroup className="ml-auto">
<Button variant="ghost" size="icon-sm" aria-label="Undo">
<UndoIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Redo">
<RedoIcon />
</Button>
</ButtonGroup>
</div>
<div className="mx-auto flex max-w-2xl flex-1 flex-col gap-8 px-10 py-10 leading-loose md:px-14 lg:py-18">
<h1 className="font-heading text-4xl leading-12 font-medium tracking-wide uppercase">
The Future of Sustainable Architecture
</h1>
<p>
As cities continue to expand at an unprecedented rate, the
architectural paradigm is shifting from mere expansion to
sustainable integration. The concrete jungles of the 20th century
are making way for structures that breathe, adapt, and give back to
their environments.
</p>
<p>
Historically, urban development has been a zero-sum game with
nature.
</p>
<h2 className="font-heading text-2xl tracking-wide uppercase">
The Living Building Challenge
</h2>
<p>
Sterling&apos;s latest project in downtown Seattle is a testament to
this new philosophy. &quot;We are no longer designing static
structures,&quot; Sterling explained during a recent site visit.
&quot;We are engineering localized ecosystems.&quot;
</p>
<p>
The building features a facade of responsive biomaterials that
adjust their porosity based on humidity and temperature,
significantly reducing the need for artificial climate control.
Rainwater is not merely channeled away but captured, filtered
through a series of integrated rooftop wetlands, and reused within
the building&apos;s greywater system.
</p>
<p className="text-sm text-muted-foreground">
This shift requires more than just innovative materials; it demands
a fundamental change in how we value space. Check with engineering
team for specific stats.
</p>
</div>
</section>
<aside className="grid grid-cols-12 gap-(--gap) xl:flex xl:flex-col">
<Card className="col-span-full md:col-span-6 lg:col-span-4">
<CardHeader>
<CardTitle>Article Details</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Issue</FieldLabel>
<Select items={ISSUES} defaultValue="summer-2024">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ISSUES.map((issue) => (
<SelectItem key={issue.value} value={issue.value}>
{issue.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Author</FieldLabel>
<Input defaultValue="Elena Rostova" />
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="col-span-full md:col-span-6 lg:col-span-4">
<CardHeader>
<CardTitle>Publication Flow</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>Required Milestones</FieldLegend>
<Field>
{MILESTONES.map((milestone) => (
<Field key={milestone.name} orientation="horizontal">
<Checkbox
defaultChecked={milestone.complete}
name={milestone.name}
id={milestone.name}
/>
<FieldLabel htmlFor={milestone.name}>
{milestone.name}
</FieldLabel>
</Field>
))}
</Field>
</FieldSet>
<Field>
<FieldLabel>Add note for editor</FieldLabel>
<Textarea placeholder="This article needs to be revised for clarity and accuracy." />
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="col-span-full lg:col-span-4">
<CardHeader>
<CardTitle>Word Count</CardTitle>
</CardHeader>
<CardContent>
<Progress value={70}>
<ProgressLabel>1,402 / 2,000 words</ProgressLabel>
<ProgressValue />
</Progress>
</CardContent>
</Card>
</aside>
</div>
)
}

View File

@@ -1,45 +0,0 @@
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Back to articles
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
EDIT ARTICLE
</h1>
</div>
<ButtonGroup className="gap-2 md:gap-4">
<Badge title="2 minutes ago">Autosaved</Badge>
<ButtonGroup className="gap-2 md:gap-4">
<Button variant="link">
Preview
<ExternalLinkIcon data-icon="inline-end" />
</Button>
<Button>Submit Draft</Button>
</ButtonGroup>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,16 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { EditorWorkspace } from "./components/editor-workspace"
import { PreviewHeader } from "./components/preview-header"
export function EditArticle() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<EditorWorkspace />
</div>
</div>
)
}

View File

@@ -1,95 +0,0 @@
import { FileTextIcon, PlusIcon } from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import { Card, CardContent } from "@/styles/base-sera/ui/card"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-sera/ui/empty"
import { Separator } from "@/styles/base-sera/ui/separator"
type Stage = {
id: string
label: string
description: string
dotClassName: string
}
const STAGES: Stage[] = [
{
id: "drafting",
label: "Drafting",
description:
"Start the writing process. Articles here are works in progress, visible only to editors and authors.",
dotClassName: "bg-amber-600",
},
{
id: "in-revision",
label: "In Revision",
description:
"Content undergoing editorial review. Track changes and word counts as pieces take shape.",
dotClassName: "bg-orange-700",
},
{
id: "final-edit",
label: "Final Edit",
description:
"The final polish before publication. Ensure all styling and factual checks are complete.",
dotClassName: "bg-foreground",
},
]
export function EmptyDirectory() {
return (
<Card className="py-24">
<CardContent className="flex flex-col items-center gap-10">
<Empty className="min-h-96">
<EmptyHeader>
<EmptyMedia
variant="icon"
className="size-14 rounded-full bg-muted/70 text-muted-foreground"
>
<FileTextIcon className="size-5" />
</EmptyMedia>
<EmptyTitle className="font-heading text-2xl tracking-normal normal-case">
A Blank Canvas
</EmptyTitle>
<EmptyDescription>
Your editorial directory is currently empty. Start building your
publication&apos;s next issue by drafting the first piece.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>
<PlusIcon data-icon="inline-start" />
Create first article
</Button>
</EmptyContent>
</Empty>
<Separator className="max-w-2xl" />
<div className="grid w-full max-w-2xl grid-cols-1 gap-8 sm:grid-cols-3">
{STAGES.map((stage) => (
<div key={stage.id} className="flex flex-col gap-2">
<Badge>
<span
aria-hidden
className={`size-1.5 rounded-full ${stage.dotClassName}`}
/>
{stage.label}
</Badge>
<p className="text-xs leading-relaxed text-muted-foreground">
{stage.description}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,37 +0,0 @@
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Editorial Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Article Directory
</h1>
</div>
<Button className="sm:ml-auto">
<PlusIcon data-icon="inline-start" />
New Article
</Button>
</div>
</header>
)
}

View File

@@ -1,16 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { EmptyDirectory } from "./components/empty-directory"
import { PreviewHeader } from "./components/preview-header"
export function EmptyState() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<EmptyDirectory />
</div>
</div>
)
}

View File

@@ -1,211 +0,0 @@
"use client"
import * as React from "react"
import {
FileTextIcon,
ImageIcon,
MoreVerticalIcon,
SearchIcon,
VideoIcon,
} from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/styles/base-sera/ui/pagination"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
import { ASSETS, type AssetType } from "../../media-library/data"
function AssetTypeIcon({
type,
className,
}: {
type: AssetType
className?: string
}) {
if (type === "MP4") {
return <VideoIcon className={className} />
}
if (type === "PDF") {
return <FileTextIcon className={className} />
}
return <ImageIcon className={className} />
}
export function AssetTable() {
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(
new Set(["1"])
)
const toggleSelection = React.useCallback((id: string) => {
setSelectedIds((previous) => {
const next = new Set(previous)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
return (
<Card>
<CardHeader>
<InputGroup className="w-full">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search files, tags, or metadata..." />
</InputGroup>
</CardHeader>
<CardContent className="px-0 py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-6" aria-label="Select" />
<TableHead className="w-20" aria-label="Preview" />
<TableHead>Filename</TableHead>
<TableHead>Type</TableHead>
<TableHead>Dimensions</TableHead>
<TableHead>Size</TableHead>
<TableHead>Uploaded By</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-10 pr-6" aria-label="Actions" />
</TableRow>
</TableHeader>
<TableBody>
{ASSETS.map((asset) => {
const isSelected = selectedIds.has(asset.id)
return (
<TableRow
key={asset.id}
data-state={isSelected ? "selected" : undefined}
className="cursor-pointer"
onClick={() => toggleSelection(asset.id)}
>
<TableCell className="pl-6">
<Checkbox
checked={isSelected}
aria-label={`Select ${asset.name}`}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggleSelection(asset.id)}
/>
</TableCell>
<TableCell>
<div className="relative flex aspect-4/3 w-16 items-center justify-center bg-muted/60 ring-1 ring-border/70 ring-inset">
{asset.duration ? (
<span className="absolute right-1 bottom-1 bg-foreground/90 px-1 text-[0.5rem] font-semibold tracking-wider text-background">
{asset.duration}
</span>
) : null}
<AssetTypeIcon
type={asset.type}
className="size-4 text-muted-foreground/60"
/>
</div>
</TableCell>
<TableCell className="text-sm font-medium text-foreground">
{asset.name}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="border px-2 py-0.5 text-[0.625rem]"
>
{asset.type}
</Badge>
</TableCell>
<TableCell className="text-sm">{asset.dimensions}</TableCell>
<TableCell className="text-sm">{asset.size}</TableCell>
<TableCell>{asset.uploadedBy}</TableCell>
<TableCell className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
{asset.date}
</TableCell>
<TableCell className="pr-6 text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-xs" />}
aria-label={`Open actions for ${asset.name}`}
onClick={(event) => event.stopPropagation()}
>
<MoreVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Preview</DropdownMenuItem>
<DropdownMenuItem>Download</DropdownMenuItem>
<DropdownMenuItem variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
<CardFooter className="justify-center py-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" text="" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">2</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" text="" />
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}

View File

@@ -1,233 +0,0 @@
"use client"
import * as React from "react"
import { addDays, format } from "date-fns"
import { CalendarIcon, FilterIcon, XIcon } from "lucide-react"
import { type DateRange } from "react-day-picker"
import { Button } from "@/styles/base-sera/ui/button"
import { Calendar } from "@/styles/base-sera/ui/calendar"
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/styles/base-sera/ui/combobox"
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/styles/base-sera/ui/field"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-sera/ui/popover"
import { RadioGroup, RadioGroupItem } from "@/styles/base-sera/ui/radio-group"
import { Slider } from "@/styles/base-sera/ui/slider"
const FILE_TYPES = [
{
id: "images",
label: "Images (JPEG, PNG, WEBP)",
defaultChecked: true,
},
{
id: "video",
label: "Video (MP4, MOV)",
defaultChecked: false,
},
{
id: "documents",
label: "Documents (PDF)",
defaultChecked: false,
},
{
id: "audio",
label: "Audio (MP3, WAV)",
defaultChecked: false,
},
]
const DATE_OPTIONS = [
{ value: "any", label: "Any time" },
{ value: "24h", label: "Past 24 hours" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
]
const TAGS = [
"architecture",
"brutalism",
"ceramics",
"design-week",
"editorial",
"exterior",
"film",
"food",
"furniture",
"interior",
"kyoto",
"minimalism",
"print",
"sustainability",
"summer-issue",
"video",
] as const
export function FilterLibrary() {
const tagAnchor = useComboboxAnchor()
const [dateRange, setDateRange] = React.useState<DateRange | undefined>({
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
to: addDays(
new Date(new Date().getFullYear(), new Date().getMonth(), 1),
21
),
})
return (
<Card>
<CardHeader className="border-b">
<CardTitle>Filter Library</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>File Type</FieldLegend>
<Field>
{FILE_TYPES.map((type) => (
<Field key={type.id} orientation="horizontal">
<Checkbox id={type.id} defaultChecked={type.defaultChecked} />
<FieldLabel htmlFor={type.id}>{type.label}</FieldLabel>
</Field>
))}
</Field>
</FieldSet>
<FieldSet>
<FieldLegend>Date Uploaded</FieldLegend>
<RadioGroup defaultValue="any">
{DATE_OPTIONS.map((option) => (
<Field key={option.value} orientation="horizontal">
<RadioGroupItem
value={option.value}
id={`date-${option.value}`}
/>
<FieldLabel htmlFor={`date-${option.value}`}>
{option.label}
</FieldLabel>
</Field>
))}
</RadioGroup>
</FieldSet>
<Field>
<FieldLabel htmlFor="custom-range">Custom Range</FieldLabel>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
id="custom-range"
className="w-full"
/>
}
>
<CalendarIcon data-icon="inline-start" />
{dateRange?.from ? (
dateRange.to ? (
<>
{format(dateRange.from, "LLL dd, y")} {" "}
{format(dateRange.to, "LLL dd, y")}
</>
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={setDateRange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</Field>
<FieldSet>
<FieldLegend>File Size</FieldLegend>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-xs font-medium tracking-wider text-muted-foreground uppercase">
<span>0 MB</span>
<span>500+ MB</span>
</div>
<Slider defaultValue={[0, 60]} max={100} step={1} />
<div className="flex items-center justify-between text-xs font-medium">
<span>Min: 0 MB</span>
<span>Max: 300 MB</span>
</div>
</div>
</FieldSet>
<FieldSet>
<FieldLegend>Tags</FieldLegend>
<Field>
<Combobox
multiple
autoHighlight
items={TAGS}
defaultValue={["architecture", "brutalism"]}
>
<ComboboxChips ref={tagAnchor}>
<ComboboxValue>
{(values) => (
<React.Fragment>
{values.map((value: string) => (
<ComboboxChip key={value}>{value}</ComboboxChip>
))}
<ComboboxChipsInput placeholder="Filter by tag..." />
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={tagAnchor}>
<ComboboxEmpty>No tags found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
<CardFooter className="flex flex-col gap-2 border-t">
<Button className="w-full">Apply Filters</Button>
<Button variant="ghost" className="w-full">
Reset
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -1,47 +0,0 @@
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Asset management
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Media Library
</h1>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button
variant="outline"
className="bg-background hover:bg-background/80"
>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
</Button>
<Button>
<UploadIcon data-icon="inline-start" />
Upload Assets
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,18 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { AssetTable } from "./components/asset-table"
import { FilterLibrary } from "./components/filter-library"
import { PreviewHeader } from "./components/preview-header"
export function MediaLibraryTable() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
<AssetTable />
<FilterLibrary />
</div>
</div>
)
}

View File

@@ -1,110 +0,0 @@
import {
DownloadIcon,
FileTextIcon,
ImageIcon,
PlusIcon,
VideoIcon,
} from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import { Card, CardContent, CardFooter } from "@/styles/base-sera/ui/card"
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/styles/base-sera/ui/item"
import { Separator } from "@/styles/base-sera/ui/separator"
import { type Asset, type AssetType } from "../data"
const TYPE_LABEL: Record<AssetType, string> = {
JPEG: "Image / JPEG",
PNG: "Image / PNG",
WEBP: "Image / WEBP",
MP4: "Video / MP4",
PDF: "Document / PDF",
}
export function AssetDetails({ asset }: { asset: Asset }) {
return (
<Card className="gap-0">
<CardContent className="flex flex-col gap-6">
<div className="flex aspect-5/4 items-center justify-center bg-muted/60 text-muted-foreground/60 ring-1 ring-border/70 ring-inset">
{asset.type === "MP4" ? (
<VideoIcon className="size-8" />
) : asset.type === "PDF" ? (
<FileTextIcon className="size-8" />
) : (
<ImageIcon className="size-8" />
)}
</div>
<h2 className="line-clamp-2 font-heading text-xl tracking-wide">
{asset.name}
</h2>
<Separator />
<dl className="flex flex-col gap-5 text-sm">
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Asset Type
</dt>
<dd>{TYPE_LABEL[asset.type]}</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Dimensions
</dt>
<dd>{asset.dimensions}</dd>
</div>
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
File Size
</dt>
<dd>{asset.size}</dd>
</div>
</div>
</dl>
<Separator />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Tags
</h3>
<Button variant="ghost" size="icon-xs" aria-label="Add tag">
<PlusIcon />
</Button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{asset.tags.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
</div>
</div>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Used In
</h3>
<div className="flex flex-col gap-2">
{asset.usedIn.map((usage) => (
<Item key={usage.title} variant="outline">
<ItemContent>
<ItemTitle>{usage.title}</ItemTitle>
<ItemDescription>{usage.role}</ItemDescription>
</ItemContent>
</Item>
))}
</div>
</div>
</CardContent>
<CardFooter className="mt-6 border-t pt-6">
<Button className="w-full">
<DownloadIcon data-icon="inline-start" />
Download
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -1,160 +0,0 @@
"use client"
import {
CheckIcon,
FileTextIcon,
ImageIcon,
SearchIcon,
VideoIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/styles/base-sera/ui/pagination"
import { ASSETS, type Asset, type AssetType } from "../data"
function AssetTypeIcon({
type,
className,
}: {
type: AssetType
className?: string
}) {
if (type === "MP4") {
return <VideoIcon className={className} />
}
if (type === "PDF") {
return <FileTextIcon className={className} />
}
return <ImageIcon className={className} />
}
export function AssetGrid({
selectedId,
onSelect,
}: {
selectedId: string
onSelect: (id: string) => void
}) {
return (
<Card>
<CardHeader>
<InputGroup className="w-full">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search files, tags, or metadata..." />
</InputGroup>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{ASSETS.map((asset) => (
<AssetGridItem
key={asset.id}
asset={asset}
selected={asset.id === selectedId}
onSelect={() => onSelect(asset.id)}
/>
))}
</div>
</CardContent>
<CardFooter className="justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">2</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}
function AssetGridItem({
asset,
selected,
onSelect,
}: {
asset: Asset
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className="group flex flex-col gap-2.5 text-left outline-none focus-visible:[&>div:first-child]:ring-2 focus-visible:[&>div:first-child]:ring-ring"
>
<div
className={cn(
"relative flex aspect-4/3 items-center justify-center bg-muted/60 ring-1 ring-border/70 transition-shadow ring-inset group-hover:ring-foreground/40",
selected && "ring-2 ring-foreground group-hover:ring-foreground"
)}
>
{selected ? (
<div className="absolute top-2 left-2 flex size-5 items-center justify-center bg-foreground text-background">
<CheckIcon className="size-3" />
</div>
) : null}
<Badge
variant="outline"
className="absolute top-2 right-2 border bg-background px-2 py-1 text-[0.625rem]"
>
{asset.type}
</Badge>
{asset.duration ? (
<Badge className="absolute bottom-2 left-2 bg-foreground px-2 py-1 text-background">
{asset.duration}
</Badge>
) : null}
<AssetTypeIcon
type={asset.type}
className="size-7 text-muted-foreground/60"
/>
</div>
<div className="flex flex-col gap-0.5 px-0.5">
<p className="line-clamp-1 text-sm font-medium">{asset.name}</p>
<p className="text-[0.625rem] font-semibold tracking-wider text-muted-foreground uppercase">
{asset.date} · {asset.size}
</p>
</div>
</button>
)
}

View File

@@ -1,47 +0,0 @@
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Asset management
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Media Library
</h1>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button
variant="outline"
className="bg-background hover:bg-background/80"
>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
</Button>
<Button>
<UploadIcon data-icon="inline-start" />
Upload Assets
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,188 +0,0 @@
export type AssetType = "JPEG" | "PNG" | "WEBP" | "MP4" | "PDF"
export type Asset = {
id: string
name: string
date: string
size: string
type: AssetType
dimensions: string
duration?: string
uploadedBy: string
uploadedByInitials: string
uploadedOn: string
tags: string[]
usedIn: { title: string; role: string }[]
}
export const ASSETS: Asset[] = [
{
id: "1",
name: "brutalism-facade-01.jpg",
date: "Oct 24",
size: "4.2 MB",
type: "JPEG",
dimensions: "4000 × 3000",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 24, 2024",
tags: ["architecture", "brutalism", "exterior", "summer-issue"],
usedIn: [
{ title: "Brutalism's Second Act", role: "Cover Image" },
{ title: "Autumn Sartorial Code", role: "Inline Gallery" },
],
},
{
id: "2",
name: "brutalism-interior-raw.jpg",
date: "Oct 24",
size: "3.8 MB",
type: "JPEG",
dimensions: "3800 × 2850",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 24, 2024",
tags: ["architecture", "brutalism", "interior"],
usedIn: [{ title: "Brutalism's Second Act", role: "Inline Gallery" }],
},
{
id: "3",
name: "seattle-living-building-diagram.png",
date: "Oct 22",
size: "1.1 MB",
type: "PNG",
dimensions: "2000 × 1500",
uploadedBy: "Sarah Jenkins",
uploadedByInitials: "SJ",
uploadedOn: "Oct 22, 2024",
tags: ["diagram", "sustainability", "seattle"],
usedIn: [
{ title: "The Future of Sustainable Architecture", role: "Diagram" },
],
},
{
id: "4",
name: "interview-sofia-coppola-clip1.mp4",
date: "Oct 18",
size: "45.0 MB",
type: "MP4",
dimensions: "1920 × 1080",
duration: "0:45",
uploadedBy: "Emma Ross",
uploadedByInitials: "ER",
uploadedOn: "Oct 18, 2024",
tags: ["video", "interview", "film"],
usedIn: [{ title: "The Aesthetics of Isolation", role: "Featured Video" }],
},
{
id: "5",
name: "kyoto-kilns-pottery-detail.jpg",
date: "Oct 15",
size: "5.6 MB",
type: "JPEG",
dimensions: "4500 × 3000",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 15, 2024",
tags: ["ceramics", "kyoto", "craft"],
usedIn: [{ title: "Kyoto's Oldest Kilns", role: "Hero Image" }],
},
{
id: "6",
name: "copenhagen-design-week-street.jpg",
date: "Oct 12",
size: "3.2 MB",
type: "JPEG",
dimensions: "3600 × 2400",
uploadedBy: "Noah Bennett",
uploadedByInitials: "NB",
uploadedOn: "Oct 12, 2024",
tags: ["copenhagen", "design-week", "street"],
usedIn: [{ title: "Field Notes from Copenhagen", role: "Inline Gallery" }],
},
{
id: "7",
name: "minimalist-chair-render.webp",
date: "Oct 10",
size: "0.8 MB",
type: "WEBP",
dimensions: "2400 × 1600",
uploadedBy: "Claire Duval",
uploadedByInitials: "CD",
uploadedOn: "Oct 10, 2024",
tags: ["furniture", "minimalism", "render"],
usedIn: [{ title: "The New Vanguard", role: "Product Shot" }],
},
{
id: "8",
name: "autumn-issue-style-guide.pdf",
date: "Oct 05",
size: "12.4 MB",
type: "PDF",
dimensions: "N/A",
uploadedBy: "Emma Ross",
uploadedByInitials: "ER",
uploadedOn: "Oct 05, 2024",
tags: ["guidelines", "internal", "autumn"],
usedIn: [{ title: "Autumn Issue 2024", role: "Reference" }],
},
{
id: "9",
name: "milan-lighting-studio-visit.jpg",
date: "Oct 09",
size: "6.1 MB",
type: "JPEG",
dimensions: "5200 × 3466",
uploadedBy: "Claire Duval",
uploadedByInitials: "CD",
uploadedOn: "Oct 09, 2024",
tags: ["milan", "lighting", "studio"],
usedIn: [
{ title: "Milan's Most Elusive Lighting Designer", role: "Hero Image" },
],
},
{
id: "10",
name: "lisbon-culinary-scene-raw.webp",
date: "Oct 14",
size: "2.4 MB",
type: "WEBP",
dimensions: "3000 × 2000",
uploadedBy: "Amara Iqbal",
uploadedByInitials: "AI",
uploadedOn: "Oct 14, 2024",
tags: ["lisbon", "food", "editorial"],
usedIn: [
{ title: "Lisbon's Quiet Culinary Renaissance", role: "Inline Gallery" },
],
},
{
id: "11",
name: "print-magazine-covers-mo...",
date: "Sep 26",
size: "8.9 MB",
type: "PNG",
dimensions: "3200 × 2400",
uploadedBy: "Mina Okafor",
uploadedByInitials: "MO",
uploadedOn: "Sep 26, 2024",
tags: ["print", "magazine", "covers"],
usedIn: [{ title: "The Return of Print", role: "Cover Image" }],
},
{
id: "12",
name: "avant-garde-furniture-trailer.mp4",
date: "Sep 30",
size: "78.2 MB",
type: "MP4",
dimensions: "3840 × 2160",
duration: "1:12",
uploadedBy: "Tommy Rhodes",
uploadedByInitials: "TR",
uploadedOn: "Sep 30, 2024",
tags: ["video", "furniture", "trailer"],
usedIn: [
{ title: "Collecting the New Avant-Garde", role: "Featured Video" },
],
},
]

View File

@@ -1,30 +0,0 @@
"use client"
import * as React from "react"
import { Separator } from "@/styles/base-sera/ui/separator"
import { AssetDetails } from "./components/asset-details"
import { AssetGrid } from "./components/asset-grid"
import { PreviewHeader } from "./components/preview-header"
import { ASSETS } from "./data"
export function MediaLibrary() {
const [selectedId, setSelectedId] = React.useState<string>(ASSETS[0].id)
const selectedAsset = React.useMemo(
() => ASSETS.find((asset) => asset.id === selectedId) ?? ASSETS[0],
[selectedId]
)
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
<AssetGrid selectedId={selectedId} onSelect={setSelectedId} />
<AssetDetails asset={selectedAsset} />
</div>
</div>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,72 +0,0 @@
import { type Metadata } from "next"
import Link from "next/link"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { Button } from "@/styles/radix-sera/ui/button"
import { AudienceAnalytics } from "./audience-analytics"
import { LazyPreview } from "./components/lazy-preview"
import "./style.css"
import { ArrowRightIcon } from "lucide-react"
import { ImagePreview } from "./components/image-preview"
const title = "Introducing Sera"
const description =
"Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles."
export const metadata: Metadata = {
title,
description,
openGraph: {
title,
description,
},
twitter: {
card: "summary_large_image",
title,
description,
},
}
export default function SeraPage() {
return (
<>
<PageHeader>
<PageHeaderHeading className="font-(family-name:--font-playfair-display) text-[2.875rem] tracking-tight!">
{title}
</PageHeaderHeading>
<PageHeaderDescription className="max-w-2xl text-pretty md:text-balance">
{description}
</PageHeaderDescription>
<PageActions className="**:[.container]:justify-start">
<Button asChild size="sm">
<Link href="/create?preset=b4xFeBLg4O">
Open in shadcn/create
<ArrowRightIcon data-icon="inline-end" />
</Link>
</Button>
</PageActions>
</PageHeader>
<ImagePreview />
<div className="container-wrapper hidden flex-1 flex-col section-soft px-0 md:flex md:px-2 md:py-12">
<div className="container flex flex-1 flex-col gap-10 px-0 3xl:max-w-[2000px] md:px-6">
<AudienceAnalytics />
<LazyPreview name="articleDirectory" />
<LazyPreview name="emptyState" />
<LazyPreview name="editArticle" />
<LazyPreview name="mediaLibrary" />
<LazyPreview name="mediaLibraryTable" />
</div>
</div>
{/* <ThemeSwitcher /> */}
</>
)
}

View File

@@ -1,495 +0,0 @@
@layer base {
.preview {
--font-sans: var(--font-noto-sans);
--font-heading: var(--font-playfair-display);
contain-intrinsic-size: auto 900px;
content-visibility: auto;
}
.theme-taupe {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.3);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.3);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.3);
--primary: oklch(0.214 0.009 43.1);
--primary-foreground: oklch(0.986 0.002 67.8);
--secondary: oklch(0.96 0.002 17.2);
--secondary-foreground: oklch(0.214 0.009 43.1);
--muted: oklch(0.96 0.002 17.2);
--muted-foreground: oklch(0.547 0.021 43.1);
--accent: oklch(0.96 0.002 17.2);
--accent-foreground: oklch(0.214 0.009 43.1);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 34.3);
--input: oklch(0.922 0.005 34.3);
--ring: oklch(0.714 0.014 41.2);
--chart-1: oklch(0.868 0.007 39.5);
--chart-2: oklch(0.547 0.021 43.1);
--chart-3: oklch(0.438 0.017 39.3);
--chart-4: oklch(0.367 0.016 35.7);
--chart-5: oklch(0.268 0.011 36.5);
--sidebar: oklch(0.986 0.002 67.8);
--sidebar-foreground: oklch(0.147 0.004 49.3);
--sidebar-primary: oklch(0.214 0.009 43.1);
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
--sidebar-accent: oklch(0.96 0.002 17.2);
--sidebar-accent-foreground: oklch(0.214 0.009 43.1);
--sidebar-border: oklch(0.922 0.005 34.3);
--sidebar-ring: oklch(0.714 0.014 41.2);
.dark & {
--background: oklch(0.147 0.004 49.3);
--foreground: oklch(0.986 0.002 67.8);
--card: oklch(0.214 0.009 43.1);
--card-foreground: oklch(0.986 0.002 67.8);
--popover: oklch(0.214 0.009 43.1);
--popover-foreground: oklch(0.986 0.002 67.8);
--primary: oklch(0.922 0.005 34.3);
--primary-foreground: oklch(0.214 0.009 43.1);
--secondary: oklch(0.268 0.011 36.5);
--secondary-foreground: oklch(0.986 0.002 67.8);
--muted: oklch(0.268 0.011 36.5);
--muted-foreground: oklch(0.714 0.014 41.2);
--accent: oklch(0.268 0.011 36.5);
--accent-foreground: oklch(0.986 0.002 67.8);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.547 0.021 43.1);
--chart-1: oklch(0.868 0.007 39.5);
--chart-2: oklch(0.547 0.021 43.1);
--chart-3: oklch(0.438 0.017 39.3);
--chart-4: oklch(0.367 0.016 35.7);
--chart-5: oklch(0.268 0.011 36.5);
--sidebar: oklch(0.214 0.009 43.1);
--sidebar-foreground: oklch(0.986 0.002 67.8);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
--sidebar-accent: oklch(0.268 0.011 36.5);
--sidebar-accent-foreground: oklch(0.986 0.002 67.8);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.547 0.021 43.1);
}
}
.theme-neutral {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
.dark & {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
}
.theme-stone {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.869 0.005 56.366);
--chart-2: oklch(0.553 0.013 58.071);
--chart-3: oklch(0.444 0.011 73.639);
--chart-4: oklch(0.374 0.01 67.558);
--chart-5: oklch(0.268 0.007 34.298);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
.dark & {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.869 0.005 56.366);
--chart-2: oklch(0.553 0.013 58.071);
--chart-3: oklch(0.444 0.011 73.639);
--chart-4: oklch(0.374 0.01 67.558);
--chart-5: oklch(0.268 0.007 34.298);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
}
.theme-zinc {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
.dark & {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
}
.theme-mauve {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.008 326);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.008 326);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.212 0.019 322.12);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.003 325.6);
--secondary-foreground: oklch(0.212 0.019 322.12);
--muted: oklch(0.96 0.003 325.6);
--muted-foreground: oklch(0.542 0.034 322.5);
--accent: oklch(0.96 0.003 325.6);
--accent-foreground: oklch(0.212 0.019 322.12);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 325.62);
--input: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02);
--chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.263 0.024 320.12);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0.008 326);
--sidebar-primary: oklch(0.212 0.019 322.12);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.96 0.003 325.6);
--sidebar-accent-foreground: oklch(0.212 0.019 322.12);
--sidebar-border: oklch(0.922 0.005 325.62);
--sidebar-ring: oklch(0.711 0.019 323.02);
.dark & {
--background: oklch(0.145 0.008 326);
--foreground: oklch(0.985 0 0);
--card: oklch(0.212 0.019 322.12);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.212 0.019 322.12);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0.005 325.62);
--primary-foreground: oklch(0.212 0.019 322.12);
--secondary: oklch(0.263 0.024 320.12);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12);
--muted-foreground: oklch(0.711 0.019 323.02);
--accent: oklch(0.263 0.024 320.12);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.542 0.034 322.5);
--chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.263 0.024 320.12);
--sidebar: oklch(0.212 0.019 322.12);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.263 0.024 320.12);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.542 0.034 322.5);
}
}
.theme-olive {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.153 0.006 107.1);
--card: oklch(1 0 0);
--card-foreground: oklch(0.153 0.006 107.1);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.153 0.006 107.1);
--primary: oklch(0.228 0.013 107.4);
--primary-foreground: oklch(0.988 0.003 106.5);
--secondary: oklch(0.966 0.005 106.5);
--secondary-foreground: oklch(0.228 0.013 107.4);
--muted: oklch(0.966 0.005 106.5);
--muted-foreground: oklch(0.58 0.031 107.3);
--accent: oklch(0.966 0.005 106.5);
--accent-foreground: oklch(0.228 0.013 107.4);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.93 0.007 106.5);
--input: oklch(0.93 0.007 106.5);
--ring: oklch(0.737 0.021 106.9);
--chart-1: oklch(0.88 0.011 106.6);
--chart-2: oklch(0.58 0.031 107.3);
--chart-3: oklch(0.466 0.025 107.3);
--chart-4: oklch(0.394 0.023 107.4);
--chart-5: oklch(0.286 0.016 107.4);
--sidebar: oklch(0.988 0.003 106.5);
--sidebar-foreground: oklch(0.153 0.006 107.1);
--sidebar-primary: oklch(0.228 0.013 107.4);
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
--sidebar-accent: oklch(0.966 0.005 106.5);
--sidebar-accent-foreground: oklch(0.228 0.013 107.4);
--sidebar-border: oklch(0.93 0.007 106.5);
--sidebar-ring: oklch(0.737 0.021 106.9);
.dark & {
--background: oklch(0.153 0.006 107.1);
--foreground: oklch(0.988 0.003 106.5);
--card: oklch(0.228 0.013 107.4);
--card-foreground: oklch(0.988 0.003 106.5);
--popover: oklch(0.228 0.013 107.4);
--popover-foreground: oklch(0.988 0.003 106.5);
--primary: oklch(0.93 0.007 106.5);
--primary-foreground: oklch(0.228 0.013 107.4);
--secondary: oklch(0.286 0.016 107.4);
--secondary-foreground: oklch(0.988 0.003 106.5);
--muted: oklch(0.286 0.016 107.4);
--muted-foreground: oklch(0.737 0.021 106.9);
--accent: oklch(0.286 0.016 107.4);
--accent-foreground: oklch(0.988 0.003 106.5);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.58 0.031 107.3);
--chart-1: oklch(0.88 0.011 106.6);
--chart-2: oklch(0.58 0.031 107.3);
--chart-3: oklch(0.466 0.025 107.3);
--chart-4: oklch(0.394 0.023 107.4);
--chart-5: oklch(0.286 0.016 107.4);
--sidebar: oklch(0.228 0.013 107.4);
--sidebar-foreground: oklch(0.988 0.003 106.5);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
--sidebar-accent: oklch(0.286 0.016 107.4);
--sidebar-accent-foreground: oklch(0.988 0.003 106.5);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.58 0.031 107.3);
}
}
.theme-mist {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.218 0.008 223.9);
--primary-foreground: oklch(0.987 0.002 197.1);
--secondary: oklch(0.963 0.002 197.1);
--secondary-foreground: oklch(0.218 0.008 223.9);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.218 0.008 223.9);
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
.dark & {
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.925 0.005 214.3);
--primary-foreground: oklch(0.218 0.008 223.9);
--secondary: oklch(0.275 0.011 216.9);
--secondary-foreground: oklch(0.987 0.002 197.1);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--sidebar: oklch(0.218 0.008 223.9);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
}
}
@utility font-heading {
font-family: var(--font-serif);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -56,7 +56,7 @@ export default function BlocksLayout({
<a href="#blocks">Browse Blocks</a>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/docs/components">View Components</Link>
<Link href="/docs/blocks">Add a block</Link>
</Button>
</PageActions>
</PageHeader>

View File

@@ -1,136 +0,0 @@
"use client"
import * as React from "react"
import { useMounted } from "@/hooks/use-mounted"
import {
BASE_COLORS,
getThemesForBaseColor,
type ChartColorName,
} from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
export function ChartColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const mounted = useMounted()
const [params, setParams] = useDesignSystemSearchParams()
const availableChartColors = React.useMemo(
() => getThemesForBaseColor(params.baseColor),
[params.baseColor]
)
const currentChartColor = React.useMemo(
() =>
availableChartColors.find((theme) => theme.name === params.chartColor),
[availableChartColors, params.chartColor]
)
const currentChartColorIsBaseColor = React.useMemo(
() => BASE_COLORS.find((baseColor) => baseColor.name === params.chartColor),
[params.chartColor]
)
React.useEffect(() => {
if (!currentChartColor && availableChartColors.length > 0) {
setParams({ chartColor: availableChartColors[0].name })
}
}, [currentChartColor, availableChartColors, setParams])
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Chart Color</div>
<div className="text-sm font-medium text-foreground">
{currentChartColor?.title}
</div>
</div>
{mounted && (
<div
style={
{
"--color":
currentChartColor?.cssVars?.dark?.[
currentChartColorIsBaseColor
? "muted-foreground"
: "primary"
],
} as React.CSSProperties
}
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
/>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-92"
>
<PickerRadioGroup
value={currentChartColor?.name}
onValueChange={(value) => {
setParams({ chartColor: value as ChartColorName })
}}
>
<PickerGroup>
{availableChartColors
.filter((theme) =>
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
)
.map((theme) => (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
))}
</PickerGroup>
<PickerSeparator />
<PickerGroup>
{availableChartColors
.filter(
(theme) =>
!BASE_COLORS.find(
(baseColor) => baseColor.name === theme.name
)
)
.map((theme) => (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="chartColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,119 +0,0 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
import { type RegistryItem } from "shadcn/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { getThemesForBaseColor, STYLES } from "@/registry/config"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-nova/ui/card"
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
import { MenuAccentPicker } from "@/app/(app)/create/components/accent-picker"
import { ActionMenu } from "@/app/(app)/create/components/action-menu"
import { BaseColorPicker } from "@/app/(app)/create/components/base-color-picker"
import { BasePicker } from "@/app/(app)/create/components/base-picker"
import { ChartColorPicker } from "@/app/(app)/create/components/chart-color-picker"
import { CopyPreset } from "@/app/(app)/create/components/copy-preset"
import { FontPicker } from "@/app/(app)/create/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/create/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
import { RandomButton } from "@/app/(app)/create/components/random-button"
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
import { StylePicker } from "@/app/(app)/create/components/style-picker"
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
// Only visible when user clicks "Create Project".
const ProjectForm = dynamic(() =>
import("@/app/(app)/create/components/project-form").then(
(m) => m.ProjectForm
)
)
export function Customizer({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const anchorRef = React.useRef<HTMLDivElement | null>(null)
const availableThemes = React.useMemo(
() => getThemesForBaseColor(params.baseColor),
[params.baseColor]
)
return (
<Card
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 shadow-xl backdrop-blur-xl md:w-(--customizer-width)"
ref={anchorRef}
size="sm"
>
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
<MainMenu />
</CardHeader>
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
<FieldGroup className="flex-row gap-2.5 py-px **:data-[slot=field-separator]:-mx-4 **:data-[slot=field-separator]:w-auto md:flex-col md:gap-3.25">
<StylePicker
styles={STYLES}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<ThemePicker
themes={availableThemes}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<ChartColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<FontPicker
label="Heading"
param="fontHeading"
fonts={FONT_HEADING_OPTIONS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FontPicker
label="Font"
param="font"
fonts={FONTS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
{isMobile && <BasePicker isMobile={isMobile} anchorRef={anchorRef} />}
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
<OpenPreset
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
label={isMobile ? "Open" : "Open Preset"}
/>
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>
<CardFooter className="-mt-3 hidden min-w-0 gap-2 md:flex md:flex-col md:**:[button,a]:w-full">
<ProjectForm />
</CardFooter>
</Card>
)
}

View File

@@ -1,158 +0,0 @@
"use client"
import * as React from "react"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerLabel,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
type FontPickerOption = {
name: string
value: string
type: string
font: {
style: {
fontFamily: string
}
} | null
}
export function FontPicker({
label,
param,
fonts,
isMobile,
anchorRef,
}: {
label: string
param: "font" | "fontHeading"
fonts: readonly FontPickerOption[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentValue = param === "font" ? params.font : params.fontHeading
const handleFontChange = React.useCallback(
(value: string) => {
setParams({
[param]: value,
} as Partial<DesignSystemSearchParams>)
},
[param, setParams]
)
const currentFont = React.useMemo(
() => fonts.find((font) => font.value === currentValue),
[fonts, currentValue]
)
const currentBodyFont = React.useMemo(
() => FONTS.find((font) => font.value === params.font),
[params.font]
)
const inheritsBodyFont = param === "fontHeading" && currentValue === "inherit"
const displayFontName = inheritsBodyFont
? currentBodyFont?.name
: currentFont?.name
const inheritFontLabel = currentBodyFont ? currentBodyFont.name : "Body font"
const groupedFonts = React.useMemo(() => {
const pickerFonts =
param === "fontHeading"
? fonts.filter((font) => font.value !== "inherit")
: fonts
const groups = new Map<string, FontPickerOption[]>()
for (const font of pickerFonts) {
const existing = groups.get(font.type)
if (existing) {
existing.push(font)
continue
}
groups.set(font.type, [font])
}
return Array.from(groups.entries()).map(([type, items]) => ({
type,
label: `${type.charAt(0).toUpperCase()}${type.slice(1)}`,
items,
}))
}, [fonts, param])
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
{displayFontName}
</div>
</div>
<div
className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5"
style={{
fontFamily:
currentFont?.font?.style.fontFamily ??
currentBodyFont?.font.style.fontFamily,
}}
>
Aa
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-96"
>
<PickerRadioGroup
value={currentValue}
onValueChange={handleFontChange}
>
{param === "fontHeading" ? (
<>
<PickerGroup>
<PickerRadioItem value="inherit" closeOnClick={isMobile}>
{inheritFontLabel}
</PickerRadioItem>
</PickerGroup>
<PickerSeparator />
</>
) : null}
{groupedFonts.map((group) => (
<PickerGroup key={group.type}>
<PickerLabel>{group.label}</PickerLabel>
{group.items.map((font) => (
<PickerRadioItem
key={font.value}
value={font.value}
closeOnClick={isMobile}
>
{font.name}
</PickerRadioItem>
))}
</PickerGroup>
))}
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param={param}
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,75 +0,0 @@
"use client"
import { lazy, Suspense } from "react"
import { SquareIcon } from "lucide-react"
import type { IconLibraryName } from "shadcn/icons"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const IconLucide = lazy(() =>
import("@/registry/icons/icon-lucide").then((mod) => ({
default: mod.IconLucide,
}))
)
const IconTabler = lazy(() =>
import("@/registry/icons/icon-tabler").then((mod) => ({
default: mod.IconTabler,
}))
)
const IconHugeicons = lazy(() =>
import("@/registry/icons/icon-hugeicons").then((mod) => ({
default: mod.IconHugeicons,
}))
)
const IconPhosphor = lazy(() =>
import("@/registry/icons/icon-phosphor").then((mod) => ({
default: mod.IconPhosphor,
}))
)
const IconRemixicon = lazy(() =>
import("@/registry/icons/icon-remixicon").then((mod) => ({
default: mod.IconRemixicon,
}))
)
// Preload all icon renderer modules so switching libraries is instant.
// These warm the browser module cache; React.lazy resolves immediately
// for modules that are already loaded.
void import("@/registry/icons/icon-lucide")
void import("@/registry/icons/icon-tabler")
void import("@/registry/icons/icon-hugeicons")
void import("@/registry/icons/icon-phosphor")
void import("@/registry/icons/icon-remixicon")
export function IconPlaceholder({
...props
}: {
[K in IconLibraryName]: string
} & React.ComponentProps<"svg">) {
const [{ iconLibrary }] = useDesignSystemSearchParams()
const iconName = props[iconLibrary]
if (!iconName) {
return null
}
return (
<Suspense fallback={<SquareIcon {...props} />}>
{iconLibrary === "lucide" && <IconLucide name={iconName} {...props} />}
{iconLibrary === "tabler" && <IconTabler name={iconName} {...props} />}
{iconLibrary === "hugeicons" && (
<IconHugeicons name={iconName} {...props} />
)}
{iconLibrary === "phosphor" && (
<IconPhosphor name={iconName} {...props} />
)}
{iconLibrary === "remixicon" && (
<IconRemixicon name={iconName} {...props} />
)}
</Suspense>
)
}

View File

@@ -1,200 +0,0 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
import { Input } from "@/styles/base-nova/ui/input"
import {
OPEN_PRESET_FORWARD_TYPE,
useOpenPreset,
} from "@/app/(app)/create/hooks/use-open-preset"
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const PRESET_EXAMPLE = "b2D0wqNxT"
const PRESET_TITLE = "Open Preset"
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
export function OpenPreset({
className,
label = "Open Preset",
}: React.ComponentProps<typeof Button> & {
label?: string
}) {
const [input, setInput] = React.useState("")
const [, setParams] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const { open, setOpen } = useOpenPreset()
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
const isInvalid = input.trim().length > 0 && nextPreset === null
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setInput("")
}
},
[setOpen]
)
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!nextPreset) {
return
}
setParams({ preset: nextPreset })
handleOpenChange(false)
},
[handleOpenChange, nextPreset, setParams]
)
const triggerClassName = cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)
const desktopTrigger = (
<Button variant="outline" className={triggerClassName} />
)
const fields = (
<Field data-invalid={isInvalid || undefined}>
<FieldLabel htmlFor="preset-code" className="sr-only">
Preset code
</FieldLabel>
<FieldContent>
<Input
id="preset-code"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={isInvalid}
className="h-10 md:h-8"
/>
</FieldContent>
</Field>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>
<Button variant="outline" className={triggerClassName}>
{label}
</Button>
</DrawerTrigger>
<DrawerContent className="dark rounded-t-2xl!">
<DrawerHeader>
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit}>
<div className="px-4 py-2">{fields}</div>
<DrawerFooter>
<Button type="submit" className="h-10" disabled={!nextPreset}>
Open
</Button>
<DrawerClose asChild>
<Button variant="outline" type="button" className="h-10">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
<DialogContent className="dark">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{PRESET_TITLE}</DialogTitle>
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
</DialogHeader>
<div className="py-4">{fields}</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>
Cancel
</DialogClose>
<Button type="submit" disabled={!nextPreset}>
Open
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function OpenPresetScript() {
return (
<Script
id="open-preset-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward O key.
document.addEventListener('keydown', function(e) {
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${OPEN_PRESET_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -1,37 +0,0 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const PREVIEW_ITEMS = [
{ label: "01", value: "preview-02" },
{ label: "02", value: "preview" },
]
export function PreviewSwitcher() {
const [params, setParams] = useDesignSystemSearchParams()
const isPreview =
params.item === "preview" || params.item.startsWith("preview-0")
if (!isPreview) {
return null
}
return (
<div className="dark absolute right-3 bottom-3 z-20 flex items-center gap-1 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
{PREVIEW_ITEMS.map((item) => (
<Button
key={item.value}
variant="ghost"
size="sm"
data-active={params.item === item.value}
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
onClick={() => setParams({ item: item.value })}
>
{item.label}
</Button>
))}
</div>
)
}

View File

@@ -1,166 +0,0 @@
"use client"
import * as React from "react"
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
import {
REDO_FORWARD_TYPE,
UNDO_FORWARD_TYPE,
} from "@/app/(app)/create/components/history-buttons"
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switcher"
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
React.useEffect(() => {
const iframe = iframeRef.current
if (!iframe) {
return
}
const sendParams = () => {
sendToIframe(iframe, "design-system-params", params)
}
if (iframe.contentWindow) {
sendParams()
}
iframe.addEventListener("load", sendParams)
return () => {
iframe.removeEventListener("load", sendParams)
}
}, [params])
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (
!iframeWindow ||
event.origin !== window.location.origin ||
event.source !== iframeWindow ||
!event.data ||
typeof event.data !== "object"
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "o",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const iframeSrc = React.useMemo(() => {
// The iframe src needs to include the serialized design system params
// for the initial load, but not be reactive to them as it would cause
// full-iframe reloads on every param change (flashes & loss of state).
// Further updates of the search params will be sent to the iframe
// via a postMessage channel, for it to sync its own history onto the host's.
return serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.base, params.item])
return (
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
<iframe
key={params.base + params.item}
ref={iframeRef}
src={iframeSrc}
className="z-10 size-full flex-1"
title="Preview"
/>
</div>
<PreviewSwitcher />
</div>
)
}

View File

@@ -1,11 +0,0 @@
"use client"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
// Returns the canonical preset code derived from the current search params.
export function usePresetCode() {
const [params] = useDesignSystemSearchParams()
return getPresetCode(params)
}

View File

@@ -1,81 +0,0 @@
"use client"
import * as React from "react"
import useSWR from "swr"
const OPEN_PRESET_KEY = "create:open-preset-open"
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
function isEditableTarget(target: EventTarget | null) {
return (
(target instanceof HTMLElement && target.isContentEditable) ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
)
}
export function useOpenPreset() {
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
OPEN_PRESET_KEY,
{
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
void setOpenData(nextOpen, { revalidate: false })
},
[setOpenData]
)
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (
e.key === "o" &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
if (isEditableTarget(e.target)) {
return
}
e.preventDefault()
void setOpenData(true, { revalidate: false })
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [setOpenData])
return {
open,
setOpen: handleOpenChange,
}
}
export function useOpenPresetTrigger() {
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const openPreset = React.useCallback(() => {
void setOpenData(true, { revalidate: false })
}, [setOpenData])
return {
openPreset,
}
}

View File

@@ -1,103 +0,0 @@
"use client"
import * as React from "react"
import useSWR from "swr"
import { DEFAULT_CONFIG, PRESETS } from "@/registry/config"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const RESET_DIALOG_KEY = "create:reset-dialog-open"
export const RESET_FORWARD_TYPE = "reset-forward"
export function useReset() {
const [params, setParams] = useDesignSystemSearchParams()
const { data: showResetDialog = false, mutate: setShowResetDialogData } =
useSWR<boolean>(RESET_DIALOG_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const reset = React.useCallback(() => {
const preset =
PRESETS.find(
(preset) => preset.base === params.base && preset.style === params.style
) ?? DEFAULT_CONFIG
setParams({
base: params.base,
style: params.style,
baseColor: preset.baseColor,
theme: preset.theme,
chartColor: preset.chartColor,
iconLibrary: preset.iconLibrary,
font: preset.font,
fontHeading: preset.fontHeading,
menuAccent: preset.menuAccent,
menuColor: preset.menuColor,
radius: preset.radius,
template: DEFAULT_CONFIG.template,
item: params.item,
})
}, [setParams, params.base, params.style, params.item])
const handleShowResetDialogChange = React.useCallback(
(open: boolean) => {
void setShowResetDialogData(open, { revalidate: false })
},
[setShowResetDialogData]
)
const confirmReset = React.useCallback(() => {
reset()
void setShowResetDialogData(false, { revalidate: false })
}, [reset, setShowResetDialogData])
const showResetDialogRef = React.useRef(showResetDialog)
React.useEffect(() => {
showResetDialogRef.current = showResetDialog
}, [showResetDialog])
const confirmResetRef = React.useRef(confirmReset)
React.useEffect(() => {
confirmResetRef.current = confirmReset
}, [confirmReset])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "R" && e.shiftKey && !e.metaKey && !e.ctrlKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
// If the dialog is already open, confirm the reset.
if (showResetDialogRef.current) {
confirmResetRef.current()
return
}
handleShowResetDialogChange(true)
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [handleShowResetDialogChange])
return {
reset,
showResetDialog,
setShowResetDialog: handleShowResetDialogChange,
confirmReset,
}
}

View File

@@ -1,250 +0,0 @@
import {
DM_Sans,
EB_Garamond,
Figtree,
Geist,
Geist_Mono,
IBM_Plex_Sans,
Instrument_Sans,
Instrument_Serif,
Inter,
JetBrains_Mono,
Lora,
Manrope,
Merriweather,
Montserrat,
Noto_Sans,
Noto_Serif,
Nunito_Sans,
Outfit,
Oxanium,
Playfair_Display,
Public_Sans,
Raleway,
Roboto,
Roboto_Slab,
Source_Sans_3,
Space_Grotesk,
} from "next/font/google"
import { FONT_DEFINITIONS, type FontName } from "@/lib/font-definitions"
type PreviewFont = ReturnType<typeof Inter>
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
})
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
})
const notoSans = Noto_Sans({
subsets: ["latin"],
variable: "--font-noto-sans",
})
const nunitoSans = Nunito_Sans({
subsets: ["latin"],
variable: "--font-nunito-sans",
})
const figtree = Figtree({
subsets: ["latin"],
variable: "--font-figtree",
})
const roboto = Roboto({
subsets: ["latin"],
variable: "--font-roboto",
})
const raleway = Raleway({
subsets: ["latin"],
variable: "--font-raleway",
})
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
})
const publicSans = Public_Sans({
subsets: ["latin"],
variable: "--font-public-sans",
})
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
})
const oxanium = Oxanium({
subsets: ["latin"],
variable: "--font-oxanium",
})
const manrope = Manrope({
subsets: ["latin"],
variable: "--font-manrope",
})
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-space-grotesk",
})
const montserrat = Montserrat({
subsets: ["latin"],
variable: "--font-montserrat",
})
const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
variable: "--font-ibm-plex-sans",
})
const sourceSans3 = Source_Sans_3({
subsets: ["latin"],
variable: "--font-source-sans-3",
})
const instrumentSans = Instrument_Sans({
subsets: ["latin"],
variable: "--font-instrument-sans",
})
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
})
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
})
const notoSerif = Noto_Serif({
subsets: ["latin"],
variable: "--font-noto-serif",
})
const robotoSlab = Roboto_Slab({
subsets: ["latin"],
variable: "--font-roboto-slab",
})
const merriweather = Merriweather({
subsets: ["latin"],
variable: "--font-merriweather",
})
const lora = Lora({
subsets: ["latin"],
variable: "--font-lora",
})
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair-display",
})
const ebGaramond = EB_Garamond({
subsets: ["latin"],
variable: "--font-eb-garamond",
})
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
variable: "--font-instrument-serif",
})
const PREVIEW_FONTS = {
geist: geistSans,
inter,
"noto-sans": notoSans,
"nunito-sans": nunitoSans,
figtree,
roboto,
raleway,
"dm-sans": dmSans,
"public-sans": publicSans,
outfit,
oxanium,
manrope,
"space-grotesk": spaceGrotesk,
montserrat,
"ibm-plex-sans": ibmPlexSans,
"source-sans-3": sourceSans3,
"instrument-sans": instrumentSans,
"jetbrains-mono": jetbrainsMono,
"geist-mono": geistMono,
"noto-serif": notoSerif,
"roboto-slab": robotoSlab,
merriweather,
lora,
"playfair-display": playfairDisplay,
"eb-garamond": ebGaramond,
"instrument-serif": instrumentSerif,
} satisfies Record<FontName, PreviewFont>
function createFontOption(name: FontName) {
const definition = FONT_DEFINITIONS.find((font) => font.name === name)
if (!definition) {
throw new Error(`Unknown font definition: ${name}`)
}
return {
name: definition.title,
value: definition.name,
font: PREVIEW_FONTS[name],
type: definition.type,
} as const
}
export const FONTS = [
createFontOption("geist"),
createFontOption("inter"),
createFontOption("noto-sans"),
createFontOption("nunito-sans"),
createFontOption("figtree"),
createFontOption("roboto"),
createFontOption("raleway"),
createFontOption("dm-sans"),
createFontOption("public-sans"),
createFontOption("outfit"),
createFontOption("oxanium"),
createFontOption("manrope"),
createFontOption("space-grotesk"),
createFontOption("montserrat"),
createFontOption("ibm-plex-sans"),
createFontOption("source-sans-3"),
createFontOption("instrument-sans"),
createFontOption("geist-mono"),
createFontOption("jetbrains-mono"),
createFontOption("noto-serif"),
createFontOption("roboto-slab"),
createFontOption("merriweather"),
createFontOption("lora"),
createFontOption("playfair-display"),
createFontOption("eb-garamond"),
createFontOption("instrument-serif"),
] as const
export type Font = (typeof FONTS)[number]
export const FONT_HEADING_OPTIONS = [
{
name: "Inherit",
value: "inherit",
font: null,
type: "default",
},
...FONTS,
] as const
export type FontHeadingOption = (typeof FONT_HEADING_OPTIONS)[number]

View File

@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest"
import { parsePresetInput } from "./parse-preset-input"
describe("parsePresetInput", () => {
it("accepts a raw preset code", () => {
expect(parsePresetInput("b0")).toBe("b0")
})
it("accepts a --preset flag", () => {
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
})
it("rejects invalid preset input", () => {
expect(parsePresetInput("open sesame")).toBeNull()
})
})

View File

@@ -1,15 +0,0 @@
import { isPresetCode } from "shadcn/preset"
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
export function parsePresetInput(value: string) {
const input = value.trim()
if (!input) {
return null
}
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
return isPresetCode(preset) ? preset : null
}

View File

@@ -1,34 +0,0 @@
import { encodePreset, type PresetConfig } from "shadcn/preset"
import { type DesignSystemConfig } from "@/registry/config"
type PresetCodeConfig = Pick<
DesignSystemConfig,
| "style"
| "baseColor"
| "theme"
| "chartColor"
| "iconLibrary"
| "font"
| "fontHeading"
| "radius"
| "menuAccent"
| "menuColor"
>
export function getPresetCode(config: PresetCodeConfig) {
const presetConfig: Partial<PresetConfig> = {
style: config.style as PresetConfig["style"],
baseColor: config.baseColor as PresetConfig["baseColor"],
theme: config.theme as PresetConfig["theme"],
chartColor: config.chartColor as PresetConfig["chartColor"],
iconLibrary: config.iconLibrary as PresetConfig["iconLibrary"],
font: config.font as PresetConfig["font"],
fontHeading: config.fontHeading as PresetConfig["fontHeading"],
radius: config.radius as PresetConfig["radius"],
menuAccent: config.menuAccent as PresetConfig["menuAccent"],
menuColor: config.menuColor as PresetConfig["menuColor"],
}
return encodePreset(presetConfig)
}

View File

@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest"
import { resolvePresetOverrides } from "./preset-query"
describe("resolvePresetOverrides", () => {
it("prefers explicit fontHeading and chartColor query params", () => {
const overrides = resolvePresetOverrides(
new URLSearchParams("fontHeading=playfair-display&chartColor=emerald"),
{
theme: "neutral",
chartColor: "blue",
fontHeading: "inherit",
}
)
expect(overrides).toEqual({
fontHeading: "playfair-display",
chartColor: "emerald",
})
})
it("falls back to decoded preset values when no overrides are present", () => {
const overrides = resolvePresetOverrides(new URLSearchParams(), {
theme: "neutral",
chartColor: "blue",
fontHeading: "inherit",
})
expect(overrides).toEqual({
fontHeading: "inherit",
chartColor: "blue",
})
})
})

View File

@@ -1,30 +0,0 @@
import { V1_CHART_COLOR_MAP, type PresetConfig } from "shadcn/preset"
import { type ChartColorName, type FontHeadingValue } from "@/registry/config"
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
export function resolvePresetOverrides(
searchParams: SearchParamsLike,
decoded: Pick<PresetConfig, "theme" | "chartColor" | "fontHeading">
) {
const hasFontHeadingOverride = searchParams.has("fontHeading")
const hasChartColorOverride = searchParams.has("chartColor")
const fontHeading = hasFontHeadingOverride
? ((searchParams.get("fontHeading") ??
decoded.fontHeading) as FontHeadingValue)
: decoded.fontHeading
const chartColor = hasChartColorOverride
? ((searchParams.get("chartColor") ??
decoded.chartColor ??
V1_CHART_COLOR_MAP[decoded.theme] ??
decoded.theme) as ChartColorName)
: (decoded.chartColor ?? V1_CHART_COLOR_MAP[decoded.theme] ?? decoded.theme)
return {
fontHeading,
chartColor,
}
}

View File

@@ -1,309 +0,0 @@
import * as React from "react"
import { useSearchParams } from "next/navigation"
import { useQueryStates } from "nuqs"
import {
createLoader,
createSerializer,
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
type inferParserType,
type Options,
} from "nuqs/server"
import { decodePreset, isPresetCode } from "shadcn/preset"
import {
BASE_COLORS,
BASES,
DEFAULT_CONFIG,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
THEMES,
type BaseColorName,
type BaseName,
type ChartColorName,
type FontHeadingValue,
type FontValue,
type IconLibraryName,
type MenuAccentValue,
type MenuColorValue,
type RadiusValue,
type StyleName,
type ThemeName,
} from "@/registry/config"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { resolvePresetOverrides } from "@/app/(app)/create/lib/preset-query"
const designSystemSearchParams = {
preset: parseAsString.withDefault("b0"),
base: parseAsStringLiteral<BaseName>(BASES.map((b) => b.name)).withDefault(
DEFAULT_CONFIG.base
),
item: parseAsString.withDefault("preview-02").withOptions({ shallow: true }),
iconLibrary: parseAsStringLiteral<IconLibraryName>(
Object.values(iconLibraries).map((i) => i.name)
).withDefault(DEFAULT_CONFIG.iconLibrary),
style: parseAsStringLiteral<StyleName>(STYLES.map((s) => s.name)).withDefault(
DEFAULT_CONFIG.style
),
theme: parseAsStringLiteral<ThemeName>(THEMES.map((t) => t.name)).withDefault(
DEFAULT_CONFIG.theme
),
chartColor: parseAsStringLiteral<ChartColorName>(
THEMES.map((t) => t.name)
).withDefault(DEFAULT_CONFIG.chartColor ?? "neutral"),
font: parseAsStringLiteral<FontValue>(FONTS.map((f) => f.value)).withDefault(
DEFAULT_CONFIG.font
),
fontHeading: parseAsStringLiteral<FontHeadingValue>([
"inherit",
...FONTS.map((f) => f.value),
]).withDefault(DEFAULT_CONFIG.fontHeading),
baseColor: parseAsStringLiteral<BaseColorName>(
BASE_COLORS.map((b) => b.name)
).withDefault(DEFAULT_CONFIG.baseColor),
menuAccent: parseAsStringLiteral<MenuAccentValue>(
MENU_ACCENTS.map((a) => a.value)
).withDefault(DEFAULT_CONFIG.menuAccent),
menuColor: parseAsStringLiteral<MenuColorValue>(
MENU_COLORS.map((m) => m.value)
).withDefault(DEFAULT_CONFIG.menuColor),
radius: parseAsStringLiteral<RadiusValue>(
RADII.map((r) => r.name)
).withDefault("default"),
template: parseAsStringLiteral([
"next",
"next-monorepo",
"start",
"start-monorepo",
"react-router",
"react-router-monorepo",
"vite",
"vite-monorepo",
"astro",
"astro-monorepo",
"laravel",
] as const).withDefault("next"),
rtl: parseAsBoolean.withDefault(false),
size: parseAsInteger.withDefault(100),
custom: parseAsBoolean.withDefault(false),
}
// Design system param keys that get encoded into the preset code.
const DESIGN_SYSTEM_KEYS = [
"style",
"baseColor",
"theme",
"chartColor",
"iconLibrary",
"font",
"fontHeading",
"radius",
"menuAccent",
"menuColor",
] as const
function normalizeFontHeading(
font: FontValue,
fontHeading: FontHeadingValue
): FontHeadingValue {
// Persist "same as body" as an explicit inherit sentinel so the body font
// can change later without freezing headings to a concrete previous value.
return fontHeading === font ? "inherit" : fontHeading
}
// Non-design-system keys that get passed through as-is.
// `base` is not encoded in preset codes — it's an architectural choice, not visual.
const NON_DESIGN_SYSTEM_KEYS = [
"base",
"item",
"preset",
"template",
"rtl",
"size",
"custom",
] as const
export const loadDesignSystemSearchParams = createLoader(
designSystemSearchParams
)
export const serializeDesignSystemSearchParams = createSerializer(
designSystemSearchParams
)
export type DesignSystemSearchParams = inferParserType<
typeof designSystemSearchParams
>
export function isTranslucentMenuColor(
menuColor?: MenuColorValue | null
): menuColor is "default-translucent" | "inverted-translucent" {
return (
menuColor === "default-translucent" || menuColor === "inverted-translucent"
)
}
function normalizePartialDesignSystemParams(
params: Partial<DesignSystemSearchParams>
): Partial<DesignSystemSearchParams> {
if (
params.menuAccent === "bold" &&
isTranslucentMenuColor(params.menuColor ?? undefined)
) {
return {
...params,
menuAccent: "subtle",
}
}
return params
}
function normalizeDesignSystemParams(
params: DesignSystemSearchParams
): DesignSystemSearchParams {
let result = {
...params,
fontHeading: normalizeFontHeading(params.font, params.fontHeading),
}
// Validate theme and chartColor against baseColor.
if (result.baseColor) {
const available = getThemesForBaseColor(result.baseColor)
const themeValid = available.some((t) => t.name === result.theme)
const chartColorValid = available.some((t) => t.name === result.chartColor)
if (!themeValid || !chartColorValid) {
const fallback = (available[0]?.name ?? result.baseColor) as ThemeName
result = {
...result,
...(!themeValid && { theme: fallback }),
...(!chartColorValid && { chartColor: fallback as ChartColorName }),
}
}
}
if (
result.menuAccent === "bold" &&
isTranslucentMenuColor(result.menuColor)
) {
return {
...result,
menuAccent: "subtle",
}
}
return result
}
// If preset param exists, decode it and overlay on raw params.
// V1 presets don't encode chartColor — fall back to the colored
// theme that base-color themes originally borrowed charts from.
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
function resolvePresetParams(
rawParams: DesignSystemSearchParams,
searchParams: SearchParamsLike
) {
if (rawParams.preset && isPresetCode(rawParams.preset)) {
const decoded = decodePreset(rawParams.preset)
if (decoded) {
const presetOverrides = resolvePresetOverrides(searchParams, decoded)
return normalizeDesignSystemParams({
...decoded,
...presetOverrides,
base: rawParams.base,
item: rawParams.item,
preset: rawParams.preset,
template: rawParams.template,
rtl: rawParams.rtl,
size: rawParams.size,
custom: rawParams.custom,
})
}
}
return normalizeDesignSystemParams(rawParams)
}
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
// - Writes: when design system params are set, encodes them into a preset code.
export function useDesignSystemSearchParams(options: Options = {}) {
const searchParams = useSearchParams()
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
shallow: false,
history: "push",
...options,
})
const params = React.useMemo(
() => resolvePresetParams(rawParams, searchParams),
[rawParams, searchParams]
)
// Use ref so setParams callback stays stable across renders.
const paramsRef = React.useRef(params)
React.useEffect(() => {
paramsRef.current = params
}, [params])
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
const setParams = React.useCallback(
(
updates:
| Partial<DesignSystemSearchParams>
| ((
old: DesignSystemSearchParams
) => Partial<DesignSystemSearchParams>),
setOptions?: Options
) => {
const resolvedUpdates = normalizePartialDesignSystemParams(
typeof updates === "function" ? updates(paramsRef.current) : updates
)
const hasDesignSystemUpdate = DESIGN_SYSTEM_KEYS.some(
(key) => key in resolvedUpdates
)
if (!hasDesignSystemUpdate) {
// No design system change, pass through directly.
return rawSetParams(resolvedUpdates as RawSetParamsInput, setOptions)
}
// Merge current decoded values with updates.
const merged = normalizeDesignSystemParams({
...paramsRef.current,
...resolvedUpdates,
})
// Encode design system fields into a preset code.
// Cast needed: merged values may include null from nuqs resets,
// but encodePreset handles missing values by falling back to defaults.
const code = getPresetCode(merged)
// Build update: set preset, clear individual DS params from URL.
const rawUpdate: Record<string, unknown> = { preset: code }
for (const key of DESIGN_SYSTEM_KEYS) {
rawUpdate[key] = null
}
// Pass through non-DS params that were explicitly in the update.
for (const key of NON_DESIGN_SYSTEM_KEYS) {
if (key in resolvedUpdates) {
rawUpdate[key] = (resolvedUpdates as Record<string, unknown>)[key]
}
}
return rawSetParams(rawUpdate as RawSetParamsInput, setOptions)
},
[rawSetParams]
)
return [params, setParams] as const
}

View File

@@ -1,115 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/registry/config"
import { buildV0Payload } from "@/app/(app)/create/lib/v0"
vi.mock("shadcn/schema", async () => {
return await vi.importActual("shadcn/schema")
})
vi.mock("shadcn/utils", async () => {
const actual = (await vi.importActual("shadcn/utils")) as {
transformFont: unknown
}
return {
transformFont: actual.transformFont,
transformIcons: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
transformMenu: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
transformRender: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
}
})
vi.mock("@/registry/bases/__index__", () => ({
Index: {
base: {
card: {
name: "card",
type: "registry:ui",
},
},
radix: {
card: {
name: "card",
type: "registry:ui",
},
},
},
}))
describe("buildV0Payload", () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_APP_URL = "http://example.test"
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url
const name = url.split("/").pop()?.replace(".json", "") ?? "component"
return new Response(
JSON.stringify({
name,
type: "registry:ui",
files: [
{
path: `registry/base-nova/ui/${name}.tsx`,
type: "registry:ui",
content: `import * as React from "react"\n\nexport function Component() {\n return <div className="cn-font-heading text-xl" />\n}\n`,
},
],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
}
)
})
)
})
afterEach(() => {
vi.unstubAllGlobals()
delete process.env.NEXT_PUBLIC_APP_URL
})
it("rewrites cn-font-heading to font-heading when heading inherits the body font", async () => {
const payload = await buildV0Payload({
...DEFAULT_CONFIG,
item: undefined,
fontHeading: "inherit",
})
const cardFile = payload.files?.find(
(file) => file.target === "components/ui/card.tsx"
)
expect(cardFile?.content).toContain("font-heading")
expect(cardFile?.content).not.toContain("cn-font-heading")
})
it("rewrites cn-font-heading to font-heading when a distinct heading font is selected", async () => {
const payload = await buildV0Payload({
...DEFAULT_CONFIG,
item: undefined,
fontHeading: "playfair-display",
})
const cardFile = payload.files?.find(
(file) => file.target === "components/ui/card.tsx"
)
expect(cardFile?.content).toContain("font-heading")
expect(cardFile?.content).not.toContain("cn-font-heading")
})
})

View File

@@ -4,7 +4,6 @@ import { mdxComponents } from "@/mdx-components"
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"
import { findNeighbour } from "fumadocs-core/page-tree"
import { replaceComponentsList } from "@/lib/llm"
import { source } from "@/lib/source"
import { absoluteUrl } from "@/lib/utils"
import { DocsBaseSwitcher } from "@/components/docs-base-switcher"
@@ -84,7 +83,7 @@ export default async function Page(props: {
const neighbours = isChangelog
? { previous: null, next: null }
: findNeighbour(source.pageTree, page.url)
const raw = replaceComponentsList(await page.data.getText("raw"))
const raw = await page.data.getText("raw")
return (
<div

View File

@@ -1,17 +1,15 @@
import Link from "next/link"
import { Button } from "@/examples/radix/ui/button"
import { mdxComponents } from "@/mdx-components"
import { IconRss } from "@tabler/icons-react"
import { getChangelogPages, type ChangelogPageData } from "@/lib/changelog"
import { absoluteUrl } from "@/lib/utils"
import { OpenInV0Cta } from "@/components/open-in-v0-cta"
import { Button } from "@/styles/radix-nova/ui/button"
export const revalidate = false
export const dynamic = "force-static"
const NUMBER_OF_LATEST_PAGES = 2
export function generateMetadata() {
return {
title: "Changelog",
@@ -36,8 +34,8 @@ export function generateMetadata() {
export default function ChangelogPage() {
const pages = getChangelogPages()
const latestPages = pages.slice(0, NUMBER_OF_LATEST_PAGES)
const olderPages = pages.slice(NUMBER_OF_LATEST_PAGES)
const latestPages = pages.slice(0, 5)
const olderPages = pages.slice(5)
return (
<div
@@ -46,7 +44,7 @@ export default function ChangelogPage() {
>
<div className="flex min-w-0 flex-1 flex-col">
<div className="h-(--top-spacing) shrink-0" />
<div className="mx-auto flex w-full max-w-160 min-w-0 flex-1 flex-col gap-6 px-4 py-6 text-neutral-800 md:px-0 lg:py-8 dark:text-neutral-300">
<div className="mx-auto flex w-full max-w-[40rem] min-w-0 flex-1 flex-col gap-6 px-4 py-6 text-neutral-800 md:px-0 lg:py-8 dark:text-neutral-300">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h1 className="scroll-m-24 text-4xl font-semibold tracking-tight sm:text-3xl">
@@ -81,7 +79,7 @@ export default function ChangelogPage() {
})}
{olderPages.length > 0 && (
<div id="more-updates" className="mb-24 scroll-mt-24">
<h2 className="mb-6 font-heading text-xl font-semibold tracking-tight">
<h2 className="font-heading mb-6 text-xl font-semibold tracking-tight">
More Updates
</h2>
<div className="grid auto-rows-fr gap-3 sm:grid-cols-2">

View File

@@ -1,11 +1,8 @@
"use client"
import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
Field,
FieldContent,
@@ -16,13 +13,13 @@ import {
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/base-nova/ui-rtl/field"
import { Input } from "@/styles/base-nova/ui-rtl/input"
import {
RadioGroup,
RadioGroupItem,
} from "@/styles/base-nova/ui-rtl/radio-group"
import { Switch } from "@/styles/base-nova/ui-rtl/switch"
} from "@/examples/base/ui-rtl/field"
import { Input } from "@/examples/base/ui-rtl/input"
import { RadioGroup, RadioGroupItem } from "@/examples/base/ui-rtl/radio-group"
import { Switch } from "@/examples/base/ui-rtl/switch"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,21 +1,8 @@
"use client"
import * as React from "react"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,7 +16,20 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
} from "@/examples/base/ui-rtl/dropdown-menu"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,22 +1,22 @@
"use client"
import * as React from "react"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/base-nova/ui-rtl/input-group"
} from "@/examples/base/ui-rtl/input-group"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui-rtl/tooltip"
} from "@/examples/base/ui-rtl/tooltip"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,10 +1,10 @@
"use client"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
const translations = {
ar: {

View File

@@ -1,17 +1,17 @@
"use client"
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-nova/ui-rtl/popover"
import { Separator } from "@/styles/base-nova/ui-rtl/separator"
import { Textarea } from "@/styles/base-nova/ui-rtl/textarea"
} from "@/examples/base/ui-rtl/popover"
import { Separator } from "@/examples/base/ui-rtl/separator"
import { Textarea } from "@/examples/base/ui-rtl/textarea"
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,15 +1,12 @@
"use client"
import { PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/styles/base-nova/ui-rtl/avatar"
import { Button } from "@/styles/base-nova/ui-rtl/button"
} from "@/examples/base/ui-rtl/avatar"
import { Button } from "@/examples/base/ui-rtl/button"
import {
Empty,
EmptyContent,
@@ -17,7 +14,10 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-nova/ui-rtl/empty"
} from "@/examples/base/ui-rtl/empty"
import { PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,8 +1,9 @@
"use client"
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
import { Field, FieldLabel } from "@/examples/base/ui-rtl/field"
import { useLanguageContext } from "@/components/language-selector"
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
import { Field, FieldLabel } from "@/styles/base-nova/ui-rtl/field"
const translations = {
ar: {

View File

@@ -1,8 +1,7 @@
"use client"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
import { Button } from "@/examples/base/ui-rtl/button"
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
import {
Field,
FieldDescription,
@@ -11,8 +10,8 @@ import {
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/styles/base-nova/ui-rtl/field"
import { Input } from "@/styles/base-nova/ui-rtl/input"
} from "@/examples/base/ui-rtl/field"
import { Input } from "@/examples/base/ui-rtl/input"
import {
Select,
SelectContent,
@@ -20,8 +19,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/base-nova/ui-rtl/select"
import { Textarea } from "@/styles/base-nova/ui-rtl/textarea"
} from "@/examples/base/ui-rtl/select"
import { Textarea } from "@/examples/base/ui-rtl/textarea"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {
@@ -139,7 +140,7 @@ export function FieldDemo() {
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="rtl-exp-month">{t.month}</FieldLabel>
<Select defaultValue="">
<Select defaultValue="" items={months}>
<SelectTrigger id="rtl-exp-month">
<SelectValue placeholder="MM" />
</SelectTrigger>
@@ -156,7 +157,7 @@ export function FieldDemo() {
</Field>
<Field>
<FieldLabel htmlFor="rtl-exp-year">{t.year}</FieldLabel>
<Select defaultValue="">
<Select defaultValue="" items={years}>
<SelectTrigger id="rtl-exp-year">
<SelectValue placeholder="YYYY" />
</SelectTrigger>

View File

@@ -1,8 +1,7 @@
"use client"
import { useLanguageContext } from "@/components/language-selector"
import { Card, CardContent } from "@/styles/base-nova/ui-rtl/card"
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
import { Card, CardContent } from "@/examples/base/ui-rtl/card"
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
import {
Field,
FieldDescription,
@@ -11,7 +10,9 @@ import {
FieldLegend,
FieldSet,
FieldTitle,
} from "@/styles/base-nova/ui-rtl/field"
} from "@/examples/base/ui-rtl/field"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,14 +1,14 @@
"use client"
import { useState } from "react"
import { useLanguageContext } from "@/components/language-selector"
import {
Field,
FieldDescription,
FieldTitle,
} from "@/styles/base-nova/ui-rtl/field"
import { Slider } from "@/styles/base-nova/ui-rtl/slider"
} from "@/examples/base/ui-rtl/field"
import { Slider } from "@/examples/base/ui-rtl/slider"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,12 +1,13 @@
"use client"
import { DirectionProvider } from "@/examples/base/ui-rtl/direction"
import { FieldSeparator } from "@/examples/base/ui-rtl/field"
import {
LanguageProvider,
LanguageSelector,
useLanguageContext,
} from "@/components/language-selector"
import { DirectionProvider } from "@/styles/base-nova/ui-rtl/direction"
import { FieldSeparator } from "@/styles/base-nova/ui-rtl/field"
import { AppearanceSettings } from "./appearance-settings"
import { ButtonGroupDemo } from "./button-group-demo"

View File

@@ -1,21 +1,21 @@
"use client"
import * as React from "react"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/base-nova/ui-rtl/input-group"
import { Label } from "@/styles/base-nova/ui-rtl/label"
} from "@/examples/base/ui-rtl/input-group"
import { Label } from "@/examples/base/ui-rtl/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-nova/ui-rtl/popover"
} from "@/examples/base/ui-rtl/popover"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,5 +1,25 @@
"use client"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/examples/base/ui-rtl/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/examples/base/ui-rtl/input-group"
import { Separator } from "@/examples/base/ui-rtl/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/examples/base/ui-rtl/tooltip"
import {
IconCheck,
IconChevronDown,
@@ -9,26 +29,6 @@ import {
import { ArrowUpIcon, Search } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/styles/base-nova/ui-rtl/input-group"
import { Separator } from "@/styles/base-nova/ui-rtl/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui-rtl/tooltip"
const translations = {
ar: {

View File

@@ -1,9 +1,6 @@
"use client"
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { Button } from "@/examples/base/ui-rtl/button"
import {
Item,
ItemActions,
@@ -11,7 +8,10 @@ import {
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/styles/base-nova/ui-rtl/item"
} from "@/examples/base/ui-rtl/item"
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,6 +1,47 @@
"use client"
import { useMemo, useState } from "react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/examples/base/ui-rtl/avatar"
import { Badge } from "@/examples/base/ui-rtl/badge"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/examples/base/ui-rtl/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/examples/base/ui-rtl/dropdown-menu"
import { Field, FieldLabel } from "@/examples/base/ui-rtl/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/examples/base/ui-rtl/input-group"
import { Popover, PopoverContent } from "@/examples/base/ui-rtl/popover"
import { Switch } from "@/examples/base/ui-rtl/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/examples/base/ui-rtl/tooltip"
import {
IconApps,
IconArrowUp,
@@ -14,47 +55,6 @@ import {
} from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/styles/base-nova/ui-rtl/avatar"
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/styles/base-nova/ui-rtl/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
import { Field, FieldLabel } from "@/styles/base-nova/ui-rtl/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/styles/base-nova/ui-rtl/input-group"
import { Popover, PopoverContent } from "@/styles/base-nova/ui-rtl/popover"
import { Switch } from "@/styles/base-nova/ui-rtl/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui-rtl/tooltip"
const translations = {
ar: {

View File

@@ -1,8 +1,9 @@
"use client"
import { Badge } from "@/examples/base/ui-rtl/badge"
import { Spinner } from "@/examples/base/ui-rtl/spinner"
import { useLanguageContext } from "@/components/language-selector"
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
import { Spinner } from "@/styles/base-nova/ui-rtl/spinner"
const translations = {
ar: {

View File

@@ -1,7 +1,6 @@
"use client"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { Button } from "@/examples/base/ui-rtl/button"
import {
Empty,
EmptyContent,
@@ -9,8 +8,10 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-nova/ui-rtl/empty"
import { Spinner } from "@/styles/base-nova/ui-rtl/spinner"
} from "@/examples/base/ui-rtl/empty"
import { Spinner } from "@/examples/base/ui-rtl/spinner"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -5,10 +5,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-slot="layout"
className="group/layout relative z-10 flex min-h-svh flex-col bg-background has-data-[slot=designer]:h-svh has-data-[slot=designer]:overflow-hidden"
className="relative z-10 flex min-h-svh flex-col bg-background"
>
<SiteHeader />
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
<main className="flex flex-1 flex-col">{children}</main>
<SiteFooter />
</div>
)

View File

@@ -0,0 +1,64 @@
import { type Metadata } from "next"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { Button } from "@/registry/new-york-v4/ui/button"
const title = "Pick a Color. Make it yours."
const description =
"Try our hand-picked themes. Copy and paste them into your project. New theme editor coming soon."
export const metadata: Metadata = {
title,
description,
openGraph: {
images: [
{
url: `/og?title=${encodeURIComponent(
title
)}&description=${encodeURIComponent(description)}`,
},
],
},
twitter: {
card: "summary_large_image",
images: [
{
url: `/og?title=${encodeURIComponent(
title
)}&description=${encodeURIComponent(description)}`,
},
],
},
}
export default function ThemesLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<PageHeader>
<Announcement />
<PageHeaderHeading>{title}</PageHeaderHeading>
<PageHeaderDescription>{description}</PageHeaderDescription>
<PageActions>
<Button asChild size="sm">
<a href="#themes">Browse Themes</a>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/docs/theming">Documentation</Link>
</Button>
</PageActions>
</PageHeader>
{children}
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { CardsDemo } from "@/components/cards"
import { ThemeCustomizer } from "@/components/theme-customizer"
export const dynamic = "force-static"
export const revalidate = false
export default function ThemesPage() {
return (
<>
<div id="themes" className="container-wrapper scroll-mt-20">
<div className="container flex items-center justify-between gap-8 px-6 py-4 md:px-8">
<ThemeCustomizer />
</div>
</div>
<div className="container-wrapper flex flex-1 flex-col section-soft pb-6">
<div className="container flex flex-1 flex-col theme-container">
<CardsDemo />
</div>
</div>
</>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -9,8 +9,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function MenuAccentPicker({
isMobile,

View File

@@ -1,8 +1,6 @@
"use client"
import Script from "next/script"
import { type RegistryItem } from "shadcn/schema"
import {
Command,
CommandDialog,
@@ -11,8 +9,10 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/styles/base-nova/ui/command"
import { useActionMenu } from "@/app/(app)/create/hooks/use-action-menu"
} from "@/examples/base/ui/command"
import { type RegistryItem } from "shadcn/schema"
import { useActionMenu } from "@/app/(create)/hooks/use-action-menu"
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { useMounted } from "@/hooks/use-mounted"
import { BASE_COLORS, type BaseColorName } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -12,8 +12,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function BaseColorPicker({
isMobile,

View File

@@ -10,8 +10,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function BasePicker({
isMobile,

Some files were not shown because too many files have changed in this diff Show More