Compare commits
64 Commits
shadcn/pac
...
shadcn@4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91403eeb63 | ||
|
|
3411d53856 | ||
|
|
efa2b38d07 | ||
|
|
d00605c5fb | ||
|
|
4bdeea4c63 | ||
|
|
f632f5d798 | ||
|
|
7d6d489f83 | ||
|
|
d987955893 | ||
|
|
7b5435ac0b | ||
|
|
f289497e35 | ||
|
|
0d266984e6 | ||
|
|
cf92d4f8f2 | ||
|
|
b7cfc364ac | ||
|
|
de385d04fc | ||
|
|
b9f78c8a35 | ||
|
|
97b9e7b0ae | ||
|
|
e4b25981bf | ||
|
|
1017410468 | ||
|
|
fa71bb8624 | ||
|
|
d99839ec2a | ||
|
|
70b6bfd687 | ||
|
|
541c08f112 | ||
|
|
420433ae6f | ||
|
|
a7d77e0cf7 | ||
|
|
7ec2acc87d | ||
|
|
eeb5d22fe5 | ||
|
|
84d1d476b1 | ||
|
|
a52a606fb5 | ||
|
|
6ba39bb720 | ||
|
|
dd4b5c287c | ||
|
|
aa534e5875 | ||
|
|
2be9640c88 | ||
|
|
56567ae21a | ||
|
|
429e258322 | ||
|
|
2f57100061 | ||
|
|
fc62d5781d | ||
|
|
d86c5e5939 | ||
|
|
8006dd1c93 | ||
|
|
1dcbb4c88a | ||
|
|
4f4ffde4aa | ||
|
|
6d7a0ed93b | ||
|
|
b909b0363f | ||
|
|
a6fa6893eb | ||
|
|
561586bd98 | ||
|
|
7ddb30aade | ||
|
|
024425d45a | ||
|
|
4bdaf48f9b | ||
|
|
e9546e87ff | ||
|
|
0b34d581f9 | ||
|
|
5c2ed5e90e | ||
|
|
e9443ccd4a | ||
|
|
1fe0fe65e8 | ||
|
|
6823bad998 | ||
|
|
398e6c3406 | ||
|
|
710cc27de7 | ||
|
|
08212a478d | ||
|
|
50dc9b506b | ||
|
|
6b5aa16668 | ||
|
|
706806a207 | ||
|
|
8a7502d7fa | ||
|
|
945298ed2d | ||
|
|
f9b216af77 | ||
|
|
6525227036 | ||
|
|
b57e192965 |
@@ -9,5 +9,6 @@
|
||||
"WebFetch(domain:github.com)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
},
|
||||
"outputStyle": "Explanatory"
|
||||
}
|
||||
|
||||
9
.github/workflows/prerelease-comment.yml
vendored
@@ -3,7 +3,7 @@ name: Write Beta Release comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release - Beta"]
|
||||
workflows: ["Release"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
@@ -11,12 +11,13 @@ jobs:
|
||||
comment:
|
||||
if: |
|
||||
github.repository_owner == 'shadcn-ui' &&
|
||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
name: Write comment to the PR
|
||||
steps:
|
||||
- name: "Comment on PR"
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -53,7 +54,7 @@ jobs:
|
||||
```
|
||||
|
||||
- name: "Remove the autorelease label once published"
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
64
.github/workflows/prerelease.yml
vendored
@@ -1,64 +0,0 @@
|
||||
# Adapted from create-t3-app.
|
||||
|
||||
name: Release - Beta
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prerelease:
|
||||
if: |
|
||||
github.repository_owner == 'shadcn-ui' &&
|
||||
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
|
||||
name: Build & Publish a beta release to NPM
|
||||
runs-on: ubuntu-latest
|
||||
environment: Preview
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Modify package.json version
|
||||
run: node .github/version-script-beta.js
|
||||
|
||||
- name: Publish Beta to NPM
|
||||
run: pnpm pub:beta
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: packages/shadcn
|
||||
|
||||
- name: Upload packaged artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
||||
path: packages/shadcn/dist/index.js
|
||||
75
.github/workflows/release.yml
vendored
@@ -2,24 +2,81 @@
|
||||
|
||||
name: Release
|
||||
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ github.repository_owner == 'shadcn-ui' }}
|
||||
name: Create a PR for release workflow
|
||||
prerelease:
|
||||
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
|
||||
name: Publish Beta to NPM
|
||||
runs-on: ubuntu-latest
|
||||
environment: Preview
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Modify package.json version
|
||||
run: node .github/version-script-beta.js
|
||||
|
||||
- name: Publish Beta to NPM
|
||||
run: pnpm pub:beta
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: packages/shadcn
|
||||
|
||||
- name: Upload packaged artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
||||
path: packages/shadcn/dist/index.js
|
||||
|
||||
release:
|
||||
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
||||
name: Create Version PR or Publish Stable Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
3
.github/workflows/test.yml
vendored
@@ -39,6 +39,9 @@ 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
|
||||
|
||||
|
||||
@@ -3,15 +3,12 @@ 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"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
16
apps/v4/app/(app)/(styles)/sera/article-directory/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
22
apps/v4/app/(app)/(styles)/sera/audience-analytics/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
46
apps/v4/app/(app)/(styles)/sera/components/image-preview.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
148
apps/v4/app/(app)/(styles)/sera/components/lazy-preview.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
"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's latest project in downtown Seattle is a testament to
|
||||
this new philosophy. "We are no longer designing static
|
||||
structures," Sterling explained during a recent site visit.
|
||||
"We are engineering localized ecosystems."
|
||||
</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'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
16
apps/v4/app/(app)/(styles)/sera/edit-article/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
16
apps/v4/app/(app)/(styles)/sera/empty-state/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
188
apps/v4/app/(app)/(styles)/sera/media-library/data.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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" },
|
||||
],
|
||||
},
|
||||
]
|
||||
30
apps/v4/app/(app)/(styles)/sera/media-library/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
BIN
apps/v4/app/(app)/(styles)/sera/opengraph-image.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
72
apps/v4/app/(app)/(styles)/sera/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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 /> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
495
apps/v4/app/(app)/(styles)/sera/style.css
Normal file
@@ -0,0 +1,495 @@
|
||||
@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);
|
||||
}
|
||||
BIN
apps/v4/app/(app)/(styles)/sera/twitter-image.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
@@ -10,6 +10,7 @@ import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
||||
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||
const presetCode = usePresetCode()
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
@@ -32,12 +33,13 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
title={label}
|
||||
className={cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
|
||||
<span className="block min-w-0 truncate">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ 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 { V0Button } from "@/app/(app)/create/components/v0-button"
|
||||
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
@@ -102,8 +102,12 @@ export function Customizer({
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
|
||||
<CopyPreset className="flex-1 md:flex-none" />
|
||||
<RandomButton className="flex-1 md:flex-none" />
|
||||
<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>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function DesignSystemProvider({
|
||||
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (style === "lyra" && radius !== "none") {
|
||||
if (style === "lyra" || (style === "sera" && radius !== "none")) {
|
||||
setSearchParams({ radius: "none" })
|
||||
}
|
||||
}, [style, radius, setSearchParams])
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
|
||||
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
||||
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
|
||||
import { useRandom } from "@/app/(app)/create/hooks/use-random"
|
||||
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
||||
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
|
||||
@@ -27,6 +28,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
const [isMac, setIsMac] = React.useState(false)
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
|
||||
const { openActionMenu } = useActionMenuTrigger()
|
||||
const { openPreset } = useOpenPresetTrigger()
|
||||
const { randomize } = useRandom()
|
||||
const { toggleTheme } = useThemeToggle()
|
||||
const { setShowResetDialog } = useReset()
|
||||
@@ -55,6 +57,9 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
Navigate...
|
||||
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={openPreset}>
|
||||
Open Preset... <PickerShortcut>O</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={randomize}>
|
||||
Shuffle <PickerShortcut>R</PickerShortcut>
|
||||
</PickerItem>
|
||||
|
||||
200
apps/v4/app/(app)/create/components/open-preset.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switc
|
||||
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,
|
||||
@@ -20,78 +21,6 @@ import {
|
||||
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
||||
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
||||
|
||||
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
event.origin !== window.location.origin
|
||||
) {
|
||||
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 === 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,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function Preview() {
|
||||
const [params] = useDesignSystemSearchParams()
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
@@ -117,6 +46,89 @@ export function Preview() {
|
||||
}, [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)
|
||||
|
||||
@@ -23,7 +23,7 @@ export function RadiusPicker({
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const isRadiusLocked = params.style === "lyra"
|
||||
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
|
||||
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
|
||||
|
||||
const currentRadius = RADII.find(
|
||||
|
||||
@@ -28,7 +28,7 @@ export function RandomButton({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="w-full text-center font-medium">Shuffle</span>
|
||||
<span className="w-full truncate text-center font-medium">Shuffle</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { type Style, type StyleName } from "@/registry/config"
|
||||
import { PRESETS, type Style, type StyleName } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
@@ -53,7 +53,24 @@ export function StylePicker({
|
||||
<PickerRadioGroup
|
||||
value={currentStyle?.name}
|
||||
onValueChange={(value) => {
|
||||
setParams({ style: value as StyleName })
|
||||
const styleName = value as StyleName
|
||||
const preset = PRESETS.find(
|
||||
(p) => p.base === params.base && p.style === styleName
|
||||
)
|
||||
setParams({
|
||||
style: styleName,
|
||||
...(preset && {
|
||||
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,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
|
||||
81
apps/v4/app/(app)/create/hooks/use-open-preset.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"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,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
DM_Sans,
|
||||
EB_Garamond,
|
||||
Figtree,
|
||||
Geist,
|
||||
Geist_Mono,
|
||||
IBM_Plex_Sans,
|
||||
Instrument_Sans,
|
||||
Instrument_Serif,
|
||||
Inter,
|
||||
JetBrains_Mono,
|
||||
Lora,
|
||||
@@ -149,6 +151,17 @@ const playfairDisplay = Playfair_Display({
|
||||
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,
|
||||
@@ -174,6 +187,8 @@ const PREVIEW_FONTS = {
|
||||
merriweather,
|
||||
lora,
|
||||
"playfair-display": playfairDisplay,
|
||||
"eb-garamond": ebGaramond,
|
||||
"instrument-serif": instrumentSerif,
|
||||
} satisfies Record<FontName, PreviewFont>
|
||||
|
||||
function createFontOption(name: FontName) {
|
||||
@@ -216,6 +231,8 @@ export const FONTS = [
|
||||
createFontOption("merriweather"),
|
||||
createFontOption("lora"),
|
||||
createFontOption("playfair-display"),
|
||||
createFontOption("eb-garamond"),
|
||||
createFontOption("instrument-serif"),
|
||||
] as const
|
||||
|
||||
export type Font = (typeof FONTS)[number]
|
||||
|
||||
17
apps/v4/app/(app)/create/lib/parse-preset-input.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
15
apps/v4/app/(app)/create/lib/parse-preset-input.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -83,7 +84,7 @@ export default async function Page(props: {
|
||||
const neighbours = isChangelog
|
||||
? { previous: null, next: null }
|
||||
: findNeighbour(source.pageTree, page.url)
|
||||
const raw = await page.data.getText("raw")
|
||||
const raw = replaceComponentsList(await page.data.getText("raw"))
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ActionMenuScript } from "@/app/(app)/create/components/action-menu"
|
||||
import { DesignSystemProvider } from "@/app/(app)/create/components/design-system-provider"
|
||||
import { HistoryScript } from "@/app/(app)/create/components/history-buttons"
|
||||
import { DarkModeScript } from "@/app/(app)/create/components/mode-switcher"
|
||||
import { OpenPresetScript } from "@/app/(app)/create/components/open-preset"
|
||||
import { PreviewStyle } from "@/app/(app)/create/components/preview-style"
|
||||
import { RandomizeScript } from "@/app/(app)/create/components/random-button"
|
||||
import {
|
||||
@@ -139,6 +140,7 @@ export default async function BlockPage({
|
||||
<PreventScrollOnFocusScript />
|
||||
<PreviewStyle />
|
||||
<ActionMenuScript />
|
||||
<OpenPresetScript />
|
||||
<RandomizeScript />
|
||||
<HistoryScript />
|
||||
<DarkModeScript />
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@import "../registry/styles/style-maia.css" layer(base);
|
||||
@import "../registry/styles/style-mira.css" layer(base);
|
||||
@import "../registry/styles/style-luma.css" layer(base);
|
||||
@import "../registry/styles/style-sera.css" layer(base);
|
||||
|
||||
@custom-variant style-vega (&:where(.style-vega *));
|
||||
@custom-variant style-nova (&:where(.style-nova *));
|
||||
@@ -16,6 +17,7 @@
|
||||
@custom-variant style-maia (&:where(.style-maia *));
|
||||
@custom-variant style-mira (&:where(.style-mira *));
|
||||
@custom-variant style-luma (&:where(.style-luma *));
|
||||
@custom-variant style-sera (&:where(.style-sera *));
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant fixed (&:is(.layout-fixed *));
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||
export function Announcement() {
|
||||
return (
|
||||
<Badge asChild variant="secondary" className="bg-muted">
|
||||
<Link href="/docs/changelog">
|
||||
Introducing Luma <ArrowRightIcon />
|
||||
<Link href="/sera">
|
||||
Introducing Sera <ArrowRightIcon />
|
||||
</Link>
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconCheck, IconCopy, IconPlus } from "@tabler/icons-react"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/registry/new-york-v4/ui/dialog"
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -25,42 +24,74 @@ import {
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/registry/new-york-v4/ui/drawer"
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/registry/new-york-v4/ui/tabs"
|
||||
} from "@/styles/base-nova/ui/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/registry/new-york-v4/ui/tooltip"
|
||||
} from "@/styles/base-nova/ui/tooltip"
|
||||
|
||||
const DirectoryAddContext = React.createContext<{
|
||||
open: (registry: { name: string }) => void
|
||||
}>({
|
||||
open: () => {},
|
||||
})
|
||||
|
||||
export function useDirectoryAdd() {
|
||||
return React.useContext(DirectoryAddContext)
|
||||
}
|
||||
|
||||
export function DirectoryAddButton({
|
||||
registry,
|
||||
}: {
|
||||
registry: { name: string }
|
||||
}) {
|
||||
const { open } = useDirectoryAdd()
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="relative z-10"
|
||||
onClick={() => open(registry)}
|
||||
>
|
||||
Add <IconPlus />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function DirectoryAddProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [config, setConfig] = useConfig()
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [selectedRegistry, setSelectedRegistry] = React.useState<{
|
||||
name: string
|
||||
} | null>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const packageManager = config.packageManager || "pnpm"
|
||||
|
||||
const commands = React.useMemo(() => {
|
||||
if (!selectedRegistry) return null
|
||||
return {
|
||||
pnpm: `pnpm dlx shadcn@latest registry add ${registry.name}`,
|
||||
npm: `npx shadcn@latest registry add ${registry.name}`,
|
||||
yarn: `yarn dlx shadcn@latest registry add ${registry.name}`,
|
||||
bun: `bunx --bun shadcn@latest registry add ${registry.name}`,
|
||||
pnpm: `pnpm dlx shadcn@latest registry add ${selectedRegistry.name}`,
|
||||
npm: `npx shadcn@latest registry add ${selectedRegistry.name}`,
|
||||
yarn: `yarn dlx shadcn@latest registry add ${selectedRegistry.name}`,
|
||||
bun: `bunx --bun shadcn@latest registry add ${selectedRegistry.name}`,
|
||||
}
|
||||
}, [registry.name])
|
||||
}, [selectedRegistry])
|
||||
|
||||
const command = commands[packageManager]
|
||||
const command = commands?.[packageManager] ?? ""
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
@@ -74,19 +105,23 @@ export function DirectoryAddButton({
|
||||
name: "copy_registry_add_command",
|
||||
properties: {
|
||||
command,
|
||||
registry: registry.name,
|
||||
registry: selectedRegistry?.name ?? "",
|
||||
},
|
||||
})
|
||||
setHasCopied(true)
|
||||
}, [command, registry.name])
|
||||
}, [command, selectedRegistry?.name])
|
||||
|
||||
const Trigger = (
|
||||
<Button size="sm" variant="outline" className="relative z-10">
|
||||
Add <IconPlus />
|
||||
</Button>
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
open: (registry: { name: string }) => {
|
||||
setSelectedRegistry(registry)
|
||||
setIsOpen(true)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const Content = (
|
||||
const Content = commands ? (
|
||||
<Tabs
|
||||
value={packageManager}
|
||||
onValueChange={(value) => {
|
||||
@@ -95,30 +130,28 @@ export function DirectoryAddButton({
|
||||
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
|
||||
})
|
||||
}}
|
||||
className="gap-0 overflow-hidden rounded-lg border"
|
||||
className="gap-0 overflow-hidden rounded-xl border"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b p-2">
|
||||
<TabsList className="h-auto rounded-none bg-transparent p-0 font-mono *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none! *:data-[slot=tabs-trigger]:data-[state=active]:border-input">
|
||||
<div className="flex items-center gap-2 border-b p-1.5">
|
||||
<TabsList className="h-auto *:data-[slot=tabs-trigger]:pt-0">
|
||||
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
|
||||
<TabsTrigger value="npm">npm</TabsTrigger>
|
||||
<TabsTrigger value="yarn">yarn</TabsTrigger>
|
||||
<TabsTrigger value="bun">bun</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto size-7 rounded-lg"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<IconCheck className="size-4" />
|
||||
) : (
|
||||
<IconCopy className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasCopied ? "Copied!" : "Copy command"}
|
||||
@@ -135,47 +168,48 @@ export function DirectoryAddButton({
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add Registry</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Run this command to add {registry.name} to your project.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">{Content}</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
||||
<DialogContent className="dialog-ring animate-none! rounded-xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run this command to add {registry.name} to your project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{Content}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DirectoryAddContext value={contextValue}>
|
||||
{children}
|
||||
{isMobile ? (
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add Registry</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Run this command to add {selectedRegistry?.name} to your
|
||||
project.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">{Content}</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="animate-none! sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run this command to add {selectedRegistry?.name} to your
|
||||
project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{Content}
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button size="sm" variant="outline" />}>
|
||||
Done
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DirectoryAddContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconArrowUpRight } from "@tabler/icons-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
IconArrowUpRight,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSearchRegistry } from "@/hooks/use-search-registry"
|
||||
import { DirectoryAddButton } from "@/components/directory-add-button"
|
||||
import globalRegistries from "@/registry/directory.json"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import {
|
||||
DirectoryAddButton,
|
||||
DirectoryAddProvider,
|
||||
} from "@/components/directory-add-button"
|
||||
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -17,9 +25,16 @@ import {
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@/registry/new-york-v4/ui/item"
|
||||
} from "@/styles/base-nova/ui/item"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
} from "@/styles/base-nova/ui/pagination"
|
||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||
|
||||
import { SearchDirectory } from "./search-directory"
|
||||
import { SearchDirectory } from "./directory-search"
|
||||
|
||||
function getHomepageUrl(homepage: string) {
|
||||
const url = new URL(homepage)
|
||||
@@ -29,55 +44,308 @@ function getHomepageUrl(homepage: string) {
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function getPageHref(pathname: string, query: string, page: number) {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (query) {
|
||||
searchParams.set("q", query)
|
||||
}
|
||||
|
||||
if (page > 1) {
|
||||
searchParams.set("page", page.toString())
|
||||
}
|
||||
|
||||
const search = searchParams.toString()
|
||||
|
||||
return search ? `${pathname}?${search}` : pathname
|
||||
}
|
||||
|
||||
function getPageNumbers(current: number, total: number) {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1) as (
|
||||
| number
|
||||
| "ellipsis"
|
||||
)[]
|
||||
}
|
||||
|
||||
const pages: (number | "ellipsis")[] = [1]
|
||||
|
||||
// Show ellipsis or page 2 directly if only one number would be hidden.
|
||||
if (current > 4) {
|
||||
pages.push("ellipsis")
|
||||
} else if (current >= 4) {
|
||||
pages.push(2)
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
// Show ellipsis or second-to-last page directly if only one number would be hidden.
|
||||
if (current < total - 3) {
|
||||
pages.push("ellipsis")
|
||||
} else if (current <= total - 3) {
|
||||
pages.push(total - 1)
|
||||
}
|
||||
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
type DirectoryPaginationLinkProps = React.ComponentProps<"a"> & {
|
||||
isActive?: boolean
|
||||
size?: React.ComponentProps<typeof Button>["size"]
|
||||
}
|
||||
|
||||
function DirectoryPaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: DirectoryPaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectoryPaginationPrevious({
|
||||
className,
|
||||
text = "Previous",
|
||||
...props
|
||||
}: DirectoryPaginationLinkProps & { text?: string }) {
|
||||
return (
|
||||
<DirectoryPaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("pl-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<IconChevronLeft className="cn-rtl-flip size-4" />
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
</DirectoryPaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectoryPaginationNext({
|
||||
className,
|
||||
text = "Next",
|
||||
...props
|
||||
}: DirectoryPaginationLinkProps & { text?: string }) {
|
||||
return (
|
||||
<DirectoryPaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("pr-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
<IconChevronRight className="cn-rtl-flip size-4" />
|
||||
</DirectoryPaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function DirectoryList() {
|
||||
const { registries } = useSearchRegistry()
|
||||
const pathname = usePathname()
|
||||
const {
|
||||
isLoading,
|
||||
paginatedRegistries,
|
||||
page,
|
||||
query,
|
||||
registries,
|
||||
totalPages,
|
||||
setPage,
|
||||
setQuery,
|
||||
} = useSearchRegistry()
|
||||
const previousHref =
|
||||
page > 1 ? getPageHref(pathname, query, page - 1) : undefined
|
||||
const nextHref =
|
||||
page < totalPages ? getPageHref(pathname, query, page + 1) : undefined
|
||||
|
||||
const handlePageChange = React.useCallback(
|
||||
(
|
||||
event: React.MouseEvent<HTMLAnchorElement>,
|
||||
targetPage: number,
|
||||
disabled = false
|
||||
) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (disabled || targetPage === page) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void setPage(targetPage)
|
||||
},
|
||||
[page, setPage]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<SearchDirectory />
|
||||
<DirectoryAddProvider>
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<DirectoryListSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<SearchDirectory
|
||||
query={query}
|
||||
registriesCount={registries.length}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
<ItemGroup className="my-8">
|
||||
{paginatedRegistries.map((registry, index) => (
|
||||
<React.Fragment key={registry.name}>
|
||||
<Item className="group/item relative gap-6 px-0">
|
||||
<ItemMedia
|
||||
variant="image"
|
||||
dangerouslySetInnerHTML={{ __html: registry.logo }}
|
||||
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
|
||||
/>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<a
|
||||
href={getHomepageUrl(registry.homepage)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external"
|
||||
className="group flex items-center gap-1"
|
||||
>
|
||||
{registry.name}{" "}
|
||||
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
|
||||
</a>
|
||||
</ItemTitle>
|
||||
{registry.description && (
|
||||
<ItemDescription className="text-pretty">
|
||||
{registry.description}
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions className="relative z-10 hidden self-start sm:flex">
|
||||
<DirectoryAddButton registry={registry} />
|
||||
</ItemActions>
|
||||
<ItemFooter className="justify-start pl-16 sm:hidden">
|
||||
<Button size="sm" variant="outline">
|
||||
View <IconArrowUpRight />
|
||||
</Button>
|
||||
<DirectoryAddButton registry={registry} />
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
{index < paginatedRegistries.length - 1 && (
|
||||
<ItemSeparator className="my-1" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<DirectoryPaginationPrevious
|
||||
href={previousHref}
|
||||
aria-disabled={page <= 1 || undefined}
|
||||
tabIndex={page <= 1 ? -1 : undefined}
|
||||
onClick={(event) =>
|
||||
handlePageChange(event, page - 1, page <= 1)
|
||||
}
|
||||
className={cn(
|
||||
page <= 1
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{getPageNumbers(page, totalPages).map((p, i) =>
|
||||
p === "ellipsis" ? (
|
||||
<PaginationItem key={`ellipsis-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<DirectoryPaginationLink
|
||||
href={getPageHref(pathname, query, p)}
|
||||
isActive={p === page}
|
||||
onClick={(event) => handlePageChange(event, p)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{p}
|
||||
</DirectoryPaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
<PaginationItem>
|
||||
<DirectoryPaginationNext
|
||||
href={nextHref}
|
||||
aria-disabled={page >= totalPages || undefined}
|
||||
tabIndex={page >= totalPages ? -1 : undefined}
|
||||
onClick={(event) =>
|
||||
handlePageChange(event, page + 1, page >= totalPages)
|
||||
}
|
||||
className={cn(
|
||||
page >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DirectoryAddProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectoryListSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<ItemGroup className="my-8">
|
||||
{registries.map((registry, index) => (
|
||||
{Array.from({ length: 10 }, (_, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Item className="group/item relative gap-6 px-0">
|
||||
<ItemMedia
|
||||
variant="image"
|
||||
dangerouslySetInnerHTML={{ __html: registry.logo }}
|
||||
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
|
||||
/>
|
||||
<Item className="relative items-start gap-6 px-0">
|
||||
<Skeleton className="size-8 rounded-lg" />
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<a
|
||||
href={getHomepageUrl(registry.homepage)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external"
|
||||
className="group flex items-center gap-1"
|
||||
>
|
||||
{registry.name}{" "}
|
||||
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
|
||||
</a>
|
||||
</ItemTitle>
|
||||
{registry.description && (
|
||||
<ItemDescription className="text-pretty">
|
||||
{registry.description}
|
||||
</ItemDescription>
|
||||
)}
|
||||
<Skeleton className="h-4 w-32 sm:w-40" />
|
||||
<Skeleton className="mt-1.5 h-4 w-full max-w-md" />
|
||||
<Skeleton className="mt-1 h-4 w-3/4 max-w-sm" />
|
||||
</ItemContent>
|
||||
<ItemActions className="relative z-10 hidden self-start sm:flex">
|
||||
<DirectoryAddButton registry={registry} />
|
||||
<ItemActions className="hidden self-start sm:flex">
|
||||
<Skeleton className="h-7 w-16 rounded-lg" />
|
||||
</ItemActions>
|
||||
<ItemFooter className="justify-start pl-16 sm:hidden">
|
||||
<Button size="sm" variant="outline">
|
||||
View <IconArrowUpRight />
|
||||
</Button>
|
||||
<DirectoryAddButton registry={registry} />
|
||||
<ItemFooter className="justify-start gap-2 pl-16 sm:hidden">
|
||||
<Skeleton className="h-9 w-20 rounded-lg" />
|
||||
<Skeleton className="h-9 w-24 rounded-lg" />
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
{index < globalRegistries.length - 1 && (
|
||||
<ItemSeparator className="my-1" />
|
||||
)}
|
||||
{index < 9 && <ItemSeparator className="my-1" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import * as React from "react"
|
||||
"use client"
|
||||
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { useSearchRegistry } from "@/hooks/use-search-registry"
|
||||
import { Field } from "@/registry/new-york-v4/ui/field"
|
||||
import { Field } from "@/styles/base-nova/ui/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/registry/new-york-v4/ui/input-group"
|
||||
|
||||
export const SearchDirectory = () => {
|
||||
const { query, registries, setQuery } = useSearchRegistry()
|
||||
|
||||
const onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
}
|
||||
} from "@/styles/base-nova/ui/input-group"
|
||||
|
||||
export function SearchDirectory({
|
||||
query,
|
||||
registriesCount,
|
||||
setQuery,
|
||||
}: {
|
||||
query: string
|
||||
registriesCount: number
|
||||
setQuery: (value: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<InputGroup>
|
||||
@@ -25,14 +26,15 @@ export const SearchDirectory = () => {
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
className="h-full"
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-muted-foreground tabular-nums sm:text-xs">
|
||||
{registries.length}{" "}
|
||||
{registries.length === 1 ? "registry" : "registries"}
|
||||
{registriesCount}{" "}
|
||||
{registriesCount === 1 ? "registry" : "registries"}
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon
|
||||
@@ -1,4 +1,4 @@
|
||||
const SHOW = false
|
||||
const SHOW = true
|
||||
|
||||
export function TailwindIndicator({
|
||||
forceMount = false,
|
||||
|
||||
@@ -3,14 +3,9 @@ title: Registry Directory
|
||||
description: Discover community registries for shadcn/ui components and blocks.
|
||||
---
|
||||
|
||||
import { TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
These registries are built into the CLI with no additional configuration required. To add a component, run: `npx shadcn add @<registry>/<component>`.
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
className="border-amber-200 bg-amber-50 font-semibold dark:border-amber-900 dark:bg-amber-950"
|
||||
>
|
||||
<Callout className="bg-muted font-semibold">
|
||||
Community registries are maintained by third-party developers. Always review
|
||||
code on installation to ensure it meets your security and quality standards.
|
||||
</Callout>
|
||||
|
||||
37
apps/v4/content/docs/changelog/2026-04-sera.mdx
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: April 2026 - Introducing Sera
|
||||
description: Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles.
|
||||
date: 2026-04-16
|
||||
---
|
||||
|
||||
Introducing Sera, a new shadcn/ui style. Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles.
|
||||
|
||||
<a href="/create?preset=b4xFeBLg4O">
|
||||
<Image
|
||||
src="/images/sera-01-light.png"
|
||||
width="2160"
|
||||
height="1832"
|
||||
alt="Sera style preview"
|
||||
className="mt-6 w-full overflow-hidden rounded-lg border dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
src="/images/sera-01-dark.png"
|
||||
width="2160"
|
||||
height="1832"
|
||||
alt="Sera style preview"
|
||||
className="mt-6 hidden w-full overflow-hidden rounded-lg border shadow-sm dark:block"
|
||||
/>
|
||||
<span className="sr-only">Try Sera in shadcn/create</span>
|
||||
</a>
|
||||
|
||||
Sera is a typography-first style built on print design principles. It pairs serif headings with sans-serif body text, uses square corners, uppercase tracking, and underlined controls to create an editorial feel for your app.
|
||||
|
||||
Like the other new styles, Sera goes beyond theming. It changes the geometry, spacing, and feel of the components so your app starts from a different visual baseline.
|
||||
|
||||
Available now in [shadcn/create](/create) for both Radix and Base UI.
|
||||
|
||||
<Button asChild size="sm">
|
||||
<Link href="/create?preset=b4xFeBLg4O" className="mt-6 no-underline!">
|
||||
Try Sera
|
||||
</Link>
|
||||
</Button>
|
||||
21
apps/v4/content/docs/changelog/2026-04-shadcn-apply.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: April 2026 - shadcn apply
|
||||
description: Switch presets in existing projects without starting over.
|
||||
date: 2026-04-08
|
||||
---
|
||||
|
||||
We added `shadcn apply` so you can switch presets in an existing project without starting over.
|
||||
|
||||
When you run `npx shadcn@latest apply` in an existing project, we apply a new preset, reinstall your existing components, and update your theme, colors, CSS variables, fonts, and icons.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply --preset b2D0vQ7G4
|
||||
```
|
||||
|
||||
The CLI keeps the current base and RTL settings from your existing project, even when the preset URL was generated with different values.
|
||||
|
||||
<Button asChild size="sm">
|
||||
<Link href="/create" className="mt-6 no-underline!">
|
||||
Try a Preset
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -144,6 +144,106 @@ npm run dev
|
||||
|
||||
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
|
||||
|
||||
## Content negotiation
|
||||
|
||||
The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint — including the root of your domain — and serve different content depending on who is asking.
|
||||
|
||||
From a single URL, you can serve:
|
||||
|
||||
- **HTML** to browsers — a landing page, documentation, or marketing site.
|
||||
- **JSON** to the `shadcn` CLI — an installable registry item.
|
||||
- **Markdown** to AI agents and LLMs — a machine-readable version of your content.
|
||||
|
||||
The client signals its preference using the `Accept` request header, and your server decides what to return.
|
||||
|
||||
### Request headers
|
||||
|
||||
When the CLI makes a request to a registry, it sends the following headers:
|
||||
|
||||
- **User-Agent**: `shadcn`
|
||||
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
|
||||
|
||||
### Root hosting
|
||||
|
||||
By checking these headers on your server, you can route CLI traffic to an installable registry item while keeping browser traffic flowing to your documentation or homepage.
|
||||
|
||||
The examples below assume your built registry item is served at `/r/index.json`. Adjust the path to match your output.
|
||||
|
||||
In Next.js, express this as a rewrite in `next.config.ts`. This keeps the negotiation in the routing layer and avoids a Proxy function for this static rewrite:
|
||||
|
||||
```typescript title="next.config.ts" showLineNumbers
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
{
|
||||
source: "/",
|
||||
has: [
|
||||
{
|
||||
type: "header",
|
||||
key: "accept",
|
||||
value: "(.*)application/vnd\\.shadcn\\.v1\\+json(.*)",
|
||||
},
|
||||
],
|
||||
destination: "/r/index.json",
|
||||
},
|
||||
{
|
||||
source: "/",
|
||||
has: [
|
||||
{
|
||||
type: "header",
|
||||
key: "user-agent",
|
||||
value: "shadcn",
|
||||
},
|
||||
],
|
||||
destination: "/r/index.json",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
headers: [{ key: "Vary", value: "Accept, User-Agent" }],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
```
|
||||
|
||||
Or, in an Express.js server:
|
||||
|
||||
```javascript title="server.js" showLineNumbers
|
||||
app.get("/", (req, res) => {
|
||||
res.vary("Accept")
|
||||
res.vary("User-Agent")
|
||||
|
||||
// Check if the client prefers the shadcn vendor type.
|
||||
if (req.accepts("application/vnd.shadcn.v1+json")) {
|
||||
return res.json(registryData)
|
||||
}
|
||||
|
||||
// Optional: Secondary check for the User-Agent.
|
||||
if (req.get("User-Agent") === "shadcn") {
|
||||
return res.json(registryData)
|
||||
}
|
||||
|
||||
// Otherwise, serve your documentation or homepage.
|
||||
res.send(htmlContent)
|
||||
})
|
||||
```
|
||||
|
||||
This enables:
|
||||
|
||||
- **Branded Registry URLs**: `shadcn add https://ui.example.com`
|
||||
- **Shorter URLs**: Users type your domain root, not `/r/` or `/registry/` sub-paths.
|
||||
- **Easy Mnemonics**: Easier for users to remember and share your registry.
|
||||
|
||||
## Publish your registry
|
||||
|
||||
To make your registry available to other developers, you can publish it by deploying your project to a public URL.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { debounce, useQueryState } from "nuqs"
|
||||
import { debounce, parseAsInteger, useQueryState } from "nuqs"
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import globalRegistries from "@/registry/directory.json"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const normalizeQuery = (query: string) =>
|
||||
query.toLowerCase().replaceAll(" ", "").replaceAll("@", "")
|
||||
|
||||
@@ -25,15 +28,44 @@ const searchDirectory = (query: string | null) => {
|
||||
return globalRegistries.filter((registry) => finderFn(registry, query))
|
||||
}
|
||||
|
||||
export const useSearchRegistry = () => {
|
||||
export function useSearchRegistry() {
|
||||
const mounted = useMounted()
|
||||
const [query, setQuery] = useQueryState("q", {
|
||||
defaultValue: "",
|
||||
limitUrlUpdates: debounce(250),
|
||||
})
|
||||
|
||||
const [page, setPage] = useQueryState("page", {
|
||||
...parseAsInteger,
|
||||
defaultValue: 1,
|
||||
history: "push",
|
||||
})
|
||||
|
||||
const currentQuery = mounted ? query : ""
|
||||
const currentPageValue = mounted ? page : 1
|
||||
|
||||
const registries = searchDirectory(currentQuery)
|
||||
const totalPages = Math.ceil(registries.length / PAGE_SIZE)
|
||||
|
||||
// Clamp page to valid range.
|
||||
const currentPage = Math.max(1, Math.min(currentPageValue, totalPages))
|
||||
|
||||
const paginatedRegistries = registries.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
registries: searchDirectory(query),
|
||||
setQuery,
|
||||
isLoading: !mounted,
|
||||
query: currentQuery,
|
||||
setQuery: (value: string | null) => {
|
||||
setQuery(value)
|
||||
setPage(null)
|
||||
},
|
||||
registries,
|
||||
paginatedRegistries,
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
setPage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +301,31 @@ export const FONT_DEFINITIONS = [
|
||||
dependency: "@fontsource-variable/playfair-display",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "eb-garamond",
|
||||
title: "EB Garamond",
|
||||
type: "serif",
|
||||
family: "'EB Garamond Variable', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-eb-garamond",
|
||||
provider: "google",
|
||||
import: "EB_Garamond",
|
||||
dependency: "@fontsource-variable/eb-garamond",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "instrument-serif",
|
||||
title: "Instrument Serif",
|
||||
type: "serif",
|
||||
family: "'Instrument Serif', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-instrument-serif",
|
||||
provider: "google",
|
||||
import: "Instrument_Serif",
|
||||
dependency: "@fontsource/instrument-serif",
|
||||
subsets: ["latin"],
|
||||
weight: ["400"],
|
||||
},
|
||||
] as const satisfies readonly FontDefinition[]
|
||||
|
||||
export type FontName = (typeof FONT_DEFINITIONS)[number]["name"]
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
Lora as FontLora,
|
||||
Geist_Mono as FontMono,
|
||||
Noto_Sans as FontNotoSans,
|
||||
Noto_Sans_Arabic as FontNotoSansArabic,
|
||||
Noto_Sans_Hebrew as FontNotoSansHebrew,
|
||||
Noto_Serif as FontNotoSerif,
|
||||
Geist as FontSans,
|
||||
Inter,
|
||||
Playfair_Display,
|
||||
} from "next/font/google"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -24,6 +28,16 @@ const fontInter = Inter({
|
||||
variable: "--font-inter",
|
||||
})
|
||||
|
||||
const fontNotoSans = FontNotoSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-noto-sans",
|
||||
})
|
||||
|
||||
const fontNotoSerif = FontNotoSerif({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-noto-serif",
|
||||
})
|
||||
|
||||
const fontNotoSansArabic = FontNotoSansArabic({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-ar",
|
||||
@@ -34,10 +48,24 @@ const fontNotoSansHebrew = FontNotoSansHebrew({
|
||||
variable: "--font-he",
|
||||
})
|
||||
|
||||
const fontLora = FontLora({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-lora",
|
||||
})
|
||||
|
||||
const fontPlayfairDisplay = Playfair_Display({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-playfair-display",
|
||||
})
|
||||
|
||||
export const fontVariables = cn(
|
||||
fontSans.variable,
|
||||
fontMono.variable,
|
||||
fontInter.variable,
|
||||
fontNotoSans.variable,
|
||||
fontNotoSerif.variable,
|
||||
fontNotoSansArabic.variable,
|
||||
fontNotoSansHebrew.variable
|
||||
fontNotoSansHebrew.variable,
|
||||
fontPlayfairDisplay.variable,
|
||||
fontLora.variable
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from "fs"
|
||||
import { ExamplesIndex } from "@/examples/__index__"
|
||||
|
||||
import { getPagesFromFolder, type PageTreeFolder } from "@/lib/page-tree"
|
||||
import { source } from "@/lib/source"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
import { Index as StylesIndex } from "@/registry/__index__"
|
||||
import { type Style } from "@/registry/_legacy-styles"
|
||||
import { BASES } from "@/registry/bases"
|
||||
@@ -35,28 +37,28 @@ function getRegistryEntry(name: string, styleName: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function getComponentsList() {
|
||||
const components = source.pageTree.children.find(
|
||||
export function replaceComponentsList(content: string) {
|
||||
const componentsFolder = source.pageTree.children.find(
|
||||
(page) => page.$id === "components"
|
||||
)
|
||||
|
||||
if (components?.type !== "folder") {
|
||||
return ""
|
||||
}
|
||||
|
||||
const list = components.children.filter(
|
||||
(component) => component.type === "page"
|
||||
)
|
||||
|
||||
return list
|
||||
.map((component) => `- [${component.name}](${component.url})`)
|
||||
.join("\n")
|
||||
const list =
|
||||
componentsFolder?.type === "folder"
|
||||
? getPagesFromFolder(componentsFolder as PageTreeFolder, "radix")
|
||||
.map((component) => {
|
||||
const slug = component.url.replace(/^\/docs\//, "").split("/")
|
||||
const description = source.getPage(slug)?.data.description?.trim()
|
||||
const url = absoluteUrl(component.url.replace("/radix/", "/"))
|
||||
return `- [${component.name}](${url})${
|
||||
description ? `: ${description}` : ""
|
||||
}`
|
||||
})
|
||||
.join("\n")
|
||||
: ""
|
||||
return content.replace(/<ComponentsList\s*\/>/g, list)
|
||||
}
|
||||
|
||||
export function processMdxForLLMs(content: string, style: Style["name"]) {
|
||||
// Replace <ComponentsList /> with a markdown list of components.
|
||||
const componentsListRegex = /<ComponentsList\s*\/>/g
|
||||
content = content.replace(componentsListRegex, getComponentsList())
|
||||
content = replaceComponentsList(content)
|
||||
|
||||
const componentPreviewRegex =
|
||||
/<ComponentPreview[\s\S]*?name="([^"]+)"[\s\S]*?\/>/g
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm registry:build && pnpm icons:dev & next dev --turbopack --port 4000",
|
||||
"dev": "pnpm icons:dev & next dev --turbopack --port 4000",
|
||||
"build": "pnpm registry:build && next build",
|
||||
"start": "next start --port 4000",
|
||||
"preview": "pnpm registry:build && next build && next start --port 4000",
|
||||
@@ -76,7 +76,7 @@
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "4.2.0",
|
||||
"shadcn": "4.3.1",
|
||||
"shiki": "^1.10.1",
|
||||
"sonner": "^2.0.0",
|
||||
"swr": "^2.3.6",
|
||||
|
||||
BIN
apps/v4/public/images/sera-01-dark.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/v4/public/images/sera-01-light.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/v4/public/images/sera-02-dark.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/v4/public/images/sera-02-light.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/v4/public/images/sera-03-dark.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/v4/public/images/sera-03-light.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/v4/public/images/sera-04-dark.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
apps/v4/public/images/sera-04-light.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
apps/v4/public/images/sera-05-dark.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/v4/public/images/sera-05-light.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
apps/v4/public/images/sera-06-dark.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/v4/public/images/sera-06-light.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
@@ -9,7 +9,8 @@
|
||||
- [components.json](https://ui.shadcn.com/docs/components-json): Configuration file for customizing the CLI and component installation.
|
||||
- [Theming](https://ui.shadcn.com/docs/theming): Guide to customizing colors, typography, and design tokens.
|
||||
- [Changelog](https://ui.shadcn.com/docs/changelog): Release notes and version history.
|
||||
- [About](https://ui.shadcn.com/docs/about): Credits and project information.
|
||||
- [Skills](https://ui.shadcn.com/docs/skills): Deep shadcn/ui knowledge for AI assistants like Claude Code.
|
||||
- [Directory](https://ui.shadcn.com/docs/directory): Community registries built into the CLI.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -28,7 +29,6 @@
|
||||
|
||||
### Form & Input
|
||||
|
||||
- [Form](https://ui.shadcn.com/docs/components/form): Building forms with React Hook Form and Zod validation.
|
||||
- [Field](https://ui.shadcn.com/docs/components/field): Field component for form inputs with labels and error messages.
|
||||
- [Button](https://ui.shadcn.com/docs/components/button): Button component with multiple variants.
|
||||
- [Button Group](https://ui.shadcn.com/docs/components/button-group): Group multiple buttons together.
|
||||
@@ -39,6 +39,7 @@
|
||||
- [Checkbox](https://ui.shadcn.com/docs/components/checkbox): Checkbox input component.
|
||||
- [Radio Group](https://ui.shadcn.com/docs/components/radio-group): Radio button group component.
|
||||
- [Select](https://ui.shadcn.com/docs/components/select): Select dropdown component.
|
||||
- [Native Select](https://ui.shadcn.com/docs/components/native-select): Styled native HTML select element.
|
||||
- [Switch](https://ui.shadcn.com/docs/components/switch): Toggle switch component.
|
||||
- [Slider](https://ui.shadcn.com/docs/components/slider): Slider input component.
|
||||
- [Calendar](https://ui.shadcn.com/docs/components/calendar): Calendar component for date selection.
|
||||
@@ -75,6 +76,7 @@
|
||||
|
||||
- [Alert](https://ui.shadcn.com/docs/components/alert): Alert component for messages and notifications.
|
||||
- [Toast](https://ui.shadcn.com/docs/components/toast): Toast notification component using Sonner.
|
||||
- [Sonner](https://ui.shadcn.com/docs/components/sonner): Opinionated toast component for React.
|
||||
- [Progress](https://ui.shadcn.com/docs/components/progress): Progress bar component.
|
||||
- [Spinner](https://ui.shadcn.com/docs/components/spinner): Loading spinner component.
|
||||
- [Skeleton](https://ui.shadcn.com/docs/components/skeleton): Skeleton loading placeholder.
|
||||
@@ -100,6 +102,7 @@
|
||||
- [Toggle](https://ui.shadcn.com/docs/components/toggle): Toggle button component.
|
||||
- [Toggle Group](https://ui.shadcn.com/docs/components/toggle-group): Group of toggle buttons.
|
||||
- [Pagination](https://ui.shadcn.com/docs/components/pagination): Pagination component for lists and tables.
|
||||
- [Direction](https://ui.shadcn.com/docs/components/direction): Text direction provider for RTL support.
|
||||
|
||||
## Dark Mode
|
||||
|
||||
@@ -109,6 +112,13 @@
|
||||
- [Dark Mode - Astro](https://ui.shadcn.com/docs/dark-mode/astro): Dark mode setup for Astro.
|
||||
- [Dark Mode - Remix](https://ui.shadcn.com/docs/dark-mode/remix): Dark mode setup for Remix.
|
||||
|
||||
## RTL
|
||||
|
||||
- [RTL](https://ui.shadcn.com/docs/rtl): Overview of right-to-left language support.
|
||||
- [RTL - Next.js](https://ui.shadcn.com/docs/rtl/next): RTL setup for Next.js.
|
||||
- [RTL - Vite](https://ui.shadcn.com/docs/rtl/vite): RTL setup for Vite.
|
||||
- [RTL - TanStack Start](https://ui.shadcn.com/docs/rtl/start): RTL setup for TanStack Start.
|
||||
|
||||
## Forms
|
||||
|
||||
- [Forms Overview](https://ui.shadcn.com/docs/forms): Guide to building forms with shadcn/ui.
|
||||
@@ -137,6 +147,11 @@
|
||||
- [FAQ](https://ui.shadcn.com/docs/registry/faq): Common questions about registries.
|
||||
- [Authentication](https://ui.shadcn.com/docs/registry/authentication): Adding authentication to your registry.
|
||||
- [Registry MCP](https://ui.shadcn.com/docs/registry/mcp): MCP integration for registries.
|
||||
- [Namespaces](https://ui.shadcn.com/docs/registry/namespace): Using multiple registries with namespace support.
|
||||
- [Add a Registry](https://ui.shadcn.com/docs/registry/registry-index): Open source registry index and how to submit yours.
|
||||
- [Open in v0](https://ui.shadcn.com/docs/registry/open-in-v0): Integrating your registry with Open in v0.
|
||||
- [registry.json](https://ui.shadcn.com/docs/registry/registry-json): `registry.json` schema for your own registry.
|
||||
- [registry-item.json](https://ui.shadcn.com/docs/registry/registry-item-json): `registry-item.json` specification for registry items.
|
||||
|
||||
### Registry Schemas
|
||||
|
||||
|
||||
@@ -215,6 +215,42 @@
|
||||
"menuAccent": "subtle",
|
||||
"menuColor": "default",
|
||||
"radius": "default"
|
||||
},
|
||||
{
|
||||
"name": "radix-sera",
|
||||
"title": "Sera (Radix)",
|
||||
"description": "Sera / Lucide / Noto Sans + Playfair Display",
|
||||
"base": "radix",
|
||||
"style": "sera",
|
||||
"baseColor": "taupe",
|
||||
"theme": "taupe",
|
||||
"chartColor": "taupe",
|
||||
"iconLibrary": "lucide",
|
||||
"font": "noto-sans",
|
||||
"fontHeading": "playfair-display",
|
||||
"item": "Item",
|
||||
"rtl": false,
|
||||
"menuAccent": "subtle",
|
||||
"menuColor": "default",
|
||||
"radius": "default"
|
||||
},
|
||||
{
|
||||
"name": "base-sera",
|
||||
"title": "Sera (Base)",
|
||||
"description": "Sera / Lucide / Noto Sans + Playfair Display",
|
||||
"base": "base",
|
||||
"style": "sera",
|
||||
"baseColor": "taupe",
|
||||
"theme": "taupe",
|
||||
"chartColor": "taupe",
|
||||
"iconLibrary": "lucide",
|
||||
"font": "noto-sans",
|
||||
"fontHeading": "playfair-display",
|
||||
"item": "Item",
|
||||
"rtl": false,
|
||||
"menuAccent": "subtle",
|
||||
"menuColor": "default",
|
||||
"radius": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@
|
||||
{
|
||||
"name": "@shadcn-editor",
|
||||
"homepage": "https://shadcn-editor.vercel.app",
|
||||
"url": "https://shadcn-editor.vercel.app/r/{name}.json",
|
||||
"url": "https://raw.githubusercontent.com/htmujahid/shadcn-editor/refs/heads/main/public/r/{name}.json",
|
||||
"description": "Accessible, Customizable, Rich Text Editor. Made with Lexical and Shadcn/UI. Open Source. Open Code."
|
||||
},
|
||||
{
|
||||
@@ -809,12 +809,6 @@
|
||||
"url": "https://shadcnspace.com/r/{name}.json",
|
||||
"description": "ShadcnSpace is a collection of extra-ordinary, highly customizable shadcn/ui components, blocks, and themes to build modern UIs with speed and clarity."
|
||||
},
|
||||
{
|
||||
"name": "@shadcn-dashboard",
|
||||
"homepage": "https://shadcn-dashboard.com",
|
||||
"url": "https://shadcn-dashboard.com/r/{name}.json",
|
||||
"description": "ShadcnDashboard is a collection of modern, production-ready dashboard layouts, components, and UI patterns built on top of shadcn/ui and Tailwind CSS. It’s designed to help developers build clean, scalable, and data-driven dashboards faster—without compromising on performance, accessibility, or customization."
|
||||
},
|
||||
{
|
||||
"name": "@icons-animated",
|
||||
"homepage": "https://icons.lndev.me",
|
||||
@@ -1024,5 +1018,23 @@
|
||||
"homepage": "https://flowkit-ui.vzkiss.com",
|
||||
"url": "https://flowkit-ui.vzkiss.com/r/{name}.json",
|
||||
"description": "Opinionated, accessible components on Base UI and shadcn-style primitives — starting with a Creatable Combobox."
|
||||
},
|
||||
{
|
||||
"name": "@termcn",
|
||||
"homepage": "https://termcn.vercel.app",
|
||||
"url": "https://termcn.vercel.app/r/{name}.json",
|
||||
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React."
|
||||
},
|
||||
{
|
||||
"name": "@remocn",
|
||||
"homepage": "https://www.remocn.dev/",
|
||||
"url": "https://www.remocn.dev/r/{name}.json",
|
||||
"description": "Production-ready components for Remotion - text animations, backgrounds, transitions, UI blocks, and full scene compositions"
|
||||
},
|
||||
{
|
||||
"name": "@exabase",
|
||||
"homepage": "https://exawizards.com/exabase/design/",
|
||||
"url": "https://exawizards.com/exabase/design/registry/{name}.json",
|
||||
"description": "A collection of UI components based on the exaBase Design System, built with React and Tailwind CSS."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/base-luma/ui/alert.tsx",
|
||||
"content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/registry/base-luma/lib/utils\"\n\nconst alertVariants = cva(\n \"group/alert relative grid w-full gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4\",\n {\n variants: {\n variant: {\n default: \"bg-card text-card-foreground\",\n destructive:\n \"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nfunction Alert({\n className,\n variant,\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof alertVariants>) {\n return (\n <div\n data-slot=\"alert\"\n role=\"alert\"\n className={cn(alertVariants({ variant }), className)}\n {...props}\n />\n )\n}\n\nfunction AlertTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-title\"\n className={cn(\n \"cn-font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction AlertDescription({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-description\"\n className={cn(\n \"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction AlertAction({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-action\"\n className={cn(\"absolute top-2.5 right-3\", className)}\n {...props}\n />\n )\n}\n\nexport { Alert, AlertTitle, AlertDescription, AlertAction }\n",
|
||||
"content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/registry/base-luma/lib/utils\"\n\nconst alertVariants = cva(\n \"group/alert relative grid w-full gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4\",\n {\n variants: {\n variant: {\n default: \"bg-card text-card-foreground\",\n destructive:\n \"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nfunction Alert({\n className,\n variant,\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof alertVariants>) {\n return (\n <div\n data-slot=\"alert\"\n role=\"alert\"\n className={cn(alertVariants({ variant }), className)}\n {...props}\n />\n )\n}\n\nfunction AlertTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-title\"\n className={cn(\n \"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction AlertDescription({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-description\"\n className={cn(\n \"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction AlertAction({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-action\"\n className={cn(\"absolute top-2.5 right-3\", className)}\n {...props}\n />\n )\n}\n\nexport { Alert, AlertTitle, AlertDescription, AlertAction }\n",
|
||||
"type": "registry:ui"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/base-luma/components/example.tsx",
|
||||
"content": "import { cn } from \"@/registry/base-luma/lib/utils\"\n\nfunction ExampleWrapper({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div className=\"w-full bg-muted dark:bg-background\">\n <div\n data-slot=\"example-wrapper\"\n className={cn(\n \"mx-auto grid min-h-screen w-full max-w-5xl min-w-0 content-center items-start gap-8 p-4 pt-2 sm:gap-12 sm:p-6 md:grid-cols-2 md:gap-8 lg:p-12 2xl:max-w-6xl\",\n\n className\n )}\n {...props}\n />\n </div>\n )\n}\n\nfunction Example({\n title,\n children,\n className,\n containerClassName,\n ...props\n}: React.ComponentProps<\"div\"> & {\n title?: string\n containerClassName?: string\n}) {\n return (\n <div\n data-slot=\"example\"\n className={cn(\n \"mx-auto flex w-full max-w-lg min-w-0 flex-col gap-1 self-stretch lg:max-w-none\",\n containerClassName\n )}\n {...props}\n >\n {title && (\n <div className=\"px-1.5 py-2 text-xs font-medium text-muted-foreground\">\n {title}\n </div>\n )}\n <div\n data-slot=\"example-content\"\n className={cn(\n \"flex min-w-0 flex-1 flex-col items-start gap-6 rounded-xl bg-card p-12 text-foreground *:[div:not([class*='w-'])]:w-full\",\n className\n )}\n >\n {children}\n </div>\n </div>\n )\n}\n\nexport { ExampleWrapper, Example }\n",
|
||||
"content": "import { cn } from \"@/registry/base-luma/lib/utils\"\n\nfunction ExampleWrapper({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div className=\"w-full bg-muted dark:bg-background\">\n <div\n data-slot=\"example-wrapper\"\n className={cn(\n \"mx-auto grid min-h-screen w-full max-w-5xl min-w-0 content-center items-start gap-8 p-4 pt-2 sm:gap-12 sm:p-6 md:grid-cols-2 md:gap-8 lg:p-12 2xl:max-w-6xl\",\n\n className\n )}\n {...props}\n />\n </div>\n )\n}\n\nfunction Example({\n title,\n children,\n className,\n containerClassName,\n ...props\n}: React.ComponentProps<\"div\"> & {\n title?: string\n containerClassName?: string\n}) {\n return (\n <div\n data-slot=\"example\"\n className={cn(\n \"mx-auto flex w-full max-w-lg min-w-0 flex-col gap-1 self-stretch lg:max-w-none\",\n containerClassName\n )}\n {...props}\n >\n {title && (\n <div className=\"px-1.5 py-2 text-xs font-medium text-muted-foreground\">\n {title}\n </div>\n )}\n <div\n data-slot=\"example-content\"\n className={cn(\n \"flex min-w-0 flex-1 flex-col items-start gap-6 rounded-xl bg-card p-12 text-foreground style-lyra:rounded-none style-sera:rounded-none *:[div:not([class*='w-'])]:w-full\",\n className\n )}\n >\n {children}\n </div>\n </div>\n )\n}\n\nexport { ExampleWrapper, Example }\n",
|
||||
"type": "registry:component"
|
||||
}
|
||||
],
|
||||
|
||||
16
apps/v4/public/r/styles/base-luma/font-eb-garamond.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "font-eb-garamond",
|
||||
"title": "EB Garamond",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'EB Garamond Variable', serif",
|
||||
"provider": "google",
|
||||
"import": "EB_Garamond",
|
||||
"variable": "--font-serif",
|
||||
"subsets": [
|
||||
"latin"
|
||||
],
|
||||
"dependency": "@fontsource-variable/eb-garamond"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "font-heading-eb-garamond",
|
||||
"title": "EB Garamond (Heading)",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'EB Garamond Variable', serif",
|
||||
"provider": "google",
|
||||
"import": "EB_Garamond",
|
||||
"variable": "--font-heading",
|
||||
"subsets": [
|
||||
"latin"
|
||||
],
|
||||
"dependency": "@fontsource-variable/eb-garamond"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "font-heading-instrument-serif",
|
||||
"title": "Instrument Serif (Heading)",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'Instrument Serif', serif",
|
||||
"provider": "google",
|
||||
"import": "Instrument_Serif",
|
||||
"variable": "--font-heading",
|
||||
"weight": [
|
||||
"400"
|
||||
],
|
||||
"subsets": [
|
||||
"latin"
|
||||
],
|
||||
"dependency": "@fontsource/instrument-serif"
|
||||
}
|
||||
}
|
||||
19
apps/v4/public/r/styles/base-luma/font-instrument-serif.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "font-instrument-serif",
|
||||
"title": "Instrument Serif",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'Instrument Serif', serif",
|
||||
"provider": "google",
|
||||
"import": "Instrument_Serif",
|
||||
"variable": "--font-serif",
|
||||
"weight": [
|
||||
"400"
|
||||
],
|
||||
"subsets": [
|
||||
"latin"
|
||||
],
|
||||
"dependency": "@fontsource/instrument-serif"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/base-luma/ui/popover.tsx",
|
||||
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\"\n\nimport { cn } from \"@/registry/base-luma/lib/utils\"\n\nfunction Popover({ ...props }: PopoverPrimitive.Root.Props) {\n return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />\n}\n\nfunction PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {\n return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />\n}\n\nfunction PopoverContent({\n className,\n align = \"center\",\n alignOffset = 0,\n side = \"bottom\",\n sideOffset = 4,\n ...props\n}: PopoverPrimitive.Popup.Props &\n Pick<\n PopoverPrimitive.Positioner.Props,\n \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n >) {\n return (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Positioner\n align={align}\n alignOffset={alignOffset}\n side={side}\n sideOffset={sideOffset}\n className=\"isolate z-50\"\n >\n <PopoverPrimitive.Popup\n data-slot=\"popover-content\"\n className={cn(\n \"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-3xl bg-popover p-4 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Positioner>\n </PopoverPrimitive.Portal>\n )\n}\n\nfunction PopoverHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"popover-header\"\n className={cn(\"flex flex-col gap-1 text-sm\", className)}\n {...props}\n />\n )\n}\n\nfunction PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {\n return (\n <PopoverPrimitive.Title\n data-slot=\"popover-title\"\n className={cn(\"cn-font-heading text-base font-medium\", className)}\n {...props}\n />\n )\n}\n\nfunction PopoverDescription({\n className,\n ...props\n}: PopoverPrimitive.Description.Props) {\n return (\n <PopoverPrimitive.Description\n data-slot=\"popover-description\"\n className={cn(\"text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\nexport {\n Popover,\n PopoverContent,\n PopoverDescription,\n PopoverHeader,\n PopoverTitle,\n PopoverTrigger,\n}\n",
|
||||
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\"\n\nimport { cn } from \"@/registry/base-luma/lib/utils\"\n\nfunction Popover({ ...props }: PopoverPrimitive.Root.Props) {\n return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />\n}\n\nfunction PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {\n return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />\n}\n\nfunction PopoverContent({\n className,\n align = \"center\",\n alignOffset = 0,\n side = \"bottom\",\n sideOffset = 4,\n ...props\n}: PopoverPrimitive.Popup.Props &\n Pick<\n PopoverPrimitive.Positioner.Props,\n \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n >) {\n return (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Positioner\n align={align}\n alignOffset={alignOffset}\n side={side}\n sideOffset={sideOffset}\n className=\"isolate z-50\"\n >\n <PopoverPrimitive.Popup\n data-slot=\"popover-content\"\n className={cn(\n \"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-3xl bg-popover p-4 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Positioner>\n </PopoverPrimitive.Portal>\n )\n}\n\nfunction PopoverHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"popover-header\"\n className={cn(\"flex flex-col gap-1 text-sm\", className)}\n {...props}\n />\n )\n}\n\nfunction PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {\n return (\n <PopoverPrimitive.Title\n data-slot=\"popover-title\"\n className={cn(\"text-base font-medium\", className)}\n {...props}\n />\n )\n}\n\nfunction PopoverDescription({\n className,\n ...props\n}: PopoverPrimitive.Description.Props) {\n return (\n <PopoverPrimitive.Description\n data-slot=\"popover-description\"\n className={cn(\"text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\nexport {\n Popover,\n PopoverContent,\n PopoverDescription,\n PopoverHeader,\n PopoverTitle,\n PopoverTrigger,\n}\n",
|
||||
"type": "registry:ui"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -3185,6 +3185,33 @@
|
||||
"dependency": "@fontsource-variable/playfair-display"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "font-eb-garamond",
|
||||
"title": "EB Garamond",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'EB Garamond Variable', serif",
|
||||
"provider": "google",
|
||||
"import": "EB_Garamond",
|
||||
"variable": "--font-serif",
|
||||
"subsets": ["latin"],
|
||||
"dependency": "@fontsource-variable/eb-garamond"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "font-instrument-serif",
|
||||
"title": "Instrument Serif",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'Instrument Serif', serif",
|
||||
"provider": "google",
|
||||
"import": "Instrument_Serif",
|
||||
"variable": "--font-serif",
|
||||
"weight": ["400"],
|
||||
"subsets": ["latin"],
|
||||
"dependency": "@fontsource/instrument-serif"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "font-heading-geist",
|
||||
"title": "Geist (Heading)",
|
||||
@@ -3496,6 +3523,33 @@
|
||||
"subsets": ["latin"],
|
||||
"dependency": "@fontsource-variable/playfair-display"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "font-heading-eb-garamond",
|
||||
"title": "EB Garamond (Heading)",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'EB Garamond Variable', serif",
|
||||
"provider": "google",
|
||||
"import": "EB_Garamond",
|
||||
"variable": "--font-heading",
|
||||
"subsets": ["latin"],
|
||||
"dependency": "@fontsource-variable/eb-garamond"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "font-heading-instrument-serif",
|
||||
"title": "Instrument Serif (Heading)",
|
||||
"type": "registry:font",
|
||||
"font": {
|
||||
"family": "'Instrument Serif', serif",
|
||||
"provider": "google",
|
||||
"import": "Instrument_Serif",
|
||||
"variable": "--font-heading",
|
||||
"weight": ["400"],
|
||||
"subsets": ["latin"],
|
||||
"dependency": "@fontsource/instrument-serif"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/base-luma/examples/switch-example.tsx",
|
||||
"content": "import {\n Example,\n ExampleWrapper,\n} from \"@/registry/base-luma/components/example\"\nimport {\n Field,\n FieldContent,\n FieldDescription,\n FieldLabel,\n FieldTitle,\n} from \"@/registry/base-luma/ui/field\"\nimport { Label } from \"@/registry/base-luma/ui/label\"\nimport { Switch } from \"@/registry/base-luma/ui/switch\"\n\nexport default function SwitchExample() {\n return (\n <ExampleWrapper>\n <SwitchBasic />\n <SwitchWithDescription />\n <SwitchDisabled />\n <SwitchSizes />\n </ExampleWrapper>\n )\n}\n\nfunction SwitchBasic() {\n return (\n <Example title=\"Basic\">\n <Field orientation=\"horizontal\">\n <Switch id=\"switch-basic\" />\n <FieldLabel htmlFor=\"switch-basic\">Airplane Mode</FieldLabel>\n </Field>\n </Example>\n )\n}\n\nfunction SwitchWithLabel() {\n return (\n <Example title=\"With Label\">\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-bluetooth\" defaultChecked />\n <Label htmlFor=\"switch-bluetooth\">Bluetooth</Label>\n </div>\n </Example>\n )\n}\n\nfunction SwitchWithDescription() {\n return (\n <Example title=\"With Description\">\n <FieldLabel htmlFor=\"switch-focus-mode\">\n <Field orientation=\"horizontal\">\n <FieldContent>\n <FieldTitle>Share across devices</FieldTitle>\n <FieldDescription>\n Focus is shared across devices, and turns off when you leave the\n app.\n </FieldDescription>\n </FieldContent>\n <Switch id=\"switch-focus-mode\" />\n </Field>\n </FieldLabel>\n </Example>\n )\n}\n\nfunction SwitchDisabled() {\n return (\n <Example title=\"Disabled\">\n <div className=\"flex flex-col gap-12\">\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-disabled-unchecked\" disabled />\n <Label htmlFor=\"switch-disabled-unchecked\">\n Disabled (Unchecked)\n </Label>\n </div>\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-disabled-checked\" defaultChecked disabled />\n <Label htmlFor=\"switch-disabled-checked\">Disabled (Checked)</Label>\n </div>\n </div>\n </Example>\n )\n}\n\nfunction SwitchSizes() {\n return (\n <Example title=\"Sizes\">\n <div className=\"flex flex-col gap-12\">\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-size-sm\" size=\"sm\" />\n <Label htmlFor=\"switch-size-sm\">Small</Label>\n </div>\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-size-default\" size=\"default\" />\n <Label htmlFor=\"switch-size-default\">Default</Label>\n </div>\n </div>\n </Example>\n )\n}\n",
|
||||
"content": "import {\n Example,\n ExampleWrapper,\n} from \"@/registry/base-luma/components/example\"\nimport {\n Field,\n FieldContent,\n FieldDescription,\n FieldLabel,\n FieldTitle,\n} from \"@/registry/base-luma/ui/field\"\nimport { Label } from \"@/registry/base-luma/ui/label\"\nimport { Switch } from \"@/registry/base-luma/ui/switch\"\n\nexport default function SwitchExample() {\n return (\n <ExampleWrapper>\n <SwitchBasic />\n <SwitchWithDescription />\n <SwitchWithLabel />\n <SwitchDisabled />\n <SwitchSizes />\n </ExampleWrapper>\n )\n}\n\nfunction SwitchBasic() {\n return (\n <Example title=\"Basic\">\n <Field orientation=\"horizontal\">\n <Switch id=\"switch-basic\" />\n <FieldLabel htmlFor=\"switch-basic\">Airplane Mode</FieldLabel>\n </Field>\n </Example>\n )\n}\n\nfunction SwitchWithLabel() {\n return (\n <Example title=\"With Label\">\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-bluetooth\" defaultChecked />\n <Label htmlFor=\"switch-bluetooth\">Bluetooth</Label>\n </div>\n </Example>\n )\n}\n\nfunction SwitchWithDescription() {\n return (\n <Example title=\"With Description\">\n <FieldLabel htmlFor=\"switch-focus-mode\">\n <Field orientation=\"horizontal\">\n <FieldContent>\n <FieldTitle>Share across devices</FieldTitle>\n <FieldDescription>\n Focus is shared across devices, and turns off when you leave the\n app.\n </FieldDescription>\n </FieldContent>\n <Switch id=\"switch-focus-mode\" />\n </Field>\n </FieldLabel>\n </Example>\n )\n}\n\nfunction SwitchDisabled() {\n return (\n <Example title=\"Disabled\">\n <div className=\"flex flex-col gap-12\">\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-disabled-unchecked\" disabled />\n <Label htmlFor=\"switch-disabled-unchecked\">\n Disabled (Unchecked)\n </Label>\n </div>\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-disabled-checked\" defaultChecked disabled />\n <Label htmlFor=\"switch-disabled-checked\">Disabled (Checked)</Label>\n </div>\n </div>\n </Example>\n )\n}\n\nfunction SwitchSizes() {\n return (\n <Example title=\"Sizes\">\n <div className=\"flex flex-col gap-12\">\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-size-sm\" size=\"sm\" />\n <Label htmlFor=\"switch-size-sm\">Small</Label>\n </div>\n <div className=\"flex items-center gap-2\">\n <Switch id=\"switch-size-default\" size=\"default\" />\n <Label htmlFor=\"switch-size-default\">Default</Label>\n </div>\n </div>\n </Example>\n )\n}\n",
|
||||
"type": "registry:example"
|
||||
}
|
||||
],
|
||||
|
||||