refactor: migrate to pnpm monorepo with Payload CMS backend and Astro frontend to support scalable website development and AI-assisted workflows

This commit is contained in:
2025-09-25 03:36:26 +08:00
parent 4efabd168c
commit 74677acf77
243 changed files with 28435 additions and 102 deletions

View File

@@ -0,0 +1,7 @@
@import '~@payloadcms/ui/scss';
.admin-bar {
@include small-break {
display: none;
}
}

View File

@@ -0,0 +1,89 @@
'use client'
import type { PayloadAdminBarProps, PayloadMeUser } from '@payloadcms/admin-bar'
import { cn } from '@/utilities/ui'
import { useSelectedLayoutSegments } from 'next/navigation'
import { PayloadAdminBar } from '@payloadcms/admin-bar'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import './index.scss'
import { getClientSideURL } from '@/utilities/getURL'
const baseClass = 'admin-bar'
const collectionLabels = {
pages: {
plural: 'Pages',
singular: 'Page',
},
posts: {
plural: 'Posts',
singular: 'Post',
},
projects: {
plural: 'Projects',
singular: 'Project',
},
}
const Title: React.FC = () => <span>Dashboard</span>
export const AdminBar: React.FC<{
adminBarProps?: PayloadAdminBarProps
}> = (props) => {
const { adminBarProps } = props || {}
const segments = useSelectedLayoutSegments()
const [show, setShow] = useState(false)
const collection = (
collectionLabels[segments?.[1] as keyof typeof collectionLabels] ? segments[1] : 'pages'
) as keyof typeof collectionLabels
const router = useRouter()
const onAuthChange = React.useCallback((user: PayloadMeUser) => {
setShow(Boolean(user?.id))
}, [])
return (
<div
className={cn(baseClass, 'py-2 bg-black text-white', {
block: show,
hidden: !show,
})}
>
<div className="container">
<PayloadAdminBar
{...adminBarProps}
className="py-2 text-white"
classNames={{
controls: 'font-medium text-white',
logo: 'text-white',
user: 'text-white',
}}
cmsURL={getClientSideURL()}
collectionSlug={collection}
collectionLabels={{
plural: collectionLabels[collection]?.plural || 'Pages',
singular: collectionLabels[collection]?.singular || 'Page',
}}
logo={<Title />}
onAuthChange={onAuthChange}
onPreviewExit={() => {
fetch('/next/exit-preview').then(() => {
router.push('/')
router.refresh()
})
}}
style={{
backgroundColor: 'transparent',
padding: 0,
position: 'relative',
zIndex: 'unset',
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
.seedButton {
appearance: none;
background: none;
border: none;
padding: 0;
text-decoration: underline;
&:hover {
cursor: pointer;
opacity: 0.85;
}
}

View File

@@ -0,0 +1,88 @@
'use client'
import React, { Fragment, useCallback, useState } from 'react'
import { toast } from '@payloadcms/ui'
import './index.scss'
const SuccessMessage: React.FC = () => (
<div>
Database seeded! You can now{' '}
<a target="_blank" href="/">
visit your website
</a>
</div>
)
export const SeedButton: React.FC = () => {
const [loading, setLoading] = useState(false)
const [seeded, setSeeded] = useState(false)
const [error, setError] = useState<null | string>(null)
const handleClick = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (seeded) {
toast.info('Database already seeded.')
return
}
if (loading) {
toast.info('Seeding already in progress.')
return
}
if (error) {
toast.error(`An error occurred, please refresh and try again.`)
return
}
setLoading(true)
try {
toast.promise(
new Promise((resolve, reject) => {
try {
fetch('/next/seed', { method: 'POST', credentials: 'include' })
.then((res) => {
if (res.ok) {
resolve(true)
setSeeded(true)
} else {
reject('An error occurred while seeding.')
}
})
.catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
}),
{
loading: 'Seeding with data....',
success: <SuccessMessage />,
error: 'An error occurred while seeding.',
},
)
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
setError(error)
}
},
[loading, seeded, error],
)
let message = ''
if (loading) message = ' (seeding...)'
if (seeded) message = ' (done!)'
if (error) message = ` (error: ${error})`
return (
<Fragment>
<button className="seedButton" onClick={handleClick}>
Seed your database
</button>
{message}
</Fragment>
)
}

View File

@@ -0,0 +1,24 @@
@import '~@payloadcms/ui/scss';
.dashboard .before-dashboard {
margin-bottom: base(1.5);
&__banner {
& h4 {
margin: 0;
}
}
&__instructions {
list-style: decimal;
margin-bottom: base(0.5);
& li {
width: 100%;
}
}
& a:hover {
opacity: 0.85;
}
}

View File

@@ -0,0 +1,74 @@
import { Banner } from '@payloadcms/ui/elements/Banner'
import React from 'react'
import { SeedButton } from './SeedButton'
import './index.scss'
const baseClass = 'before-dashboard'
const BeforeDashboard: React.FC = () => {
return (
<div className={baseClass}>
<Banner className={`${baseClass}__banner`} type="success">
<h4>Welcome to your dashboard!</h4>
</Banner>
Here&apos;s what to do next:
<ul className={`${baseClass}__instructions`}>
<li>
<SeedButton />
{' with a few pages, posts, and projects to jump-start your new site, then '}
<a href="/" target="_blank">
visit your website
</a>
{' to see the results.'}
</li>
<li>
If you created this repo using Payload Cloud, head over to GitHub and clone it to your
local machine. It will be under the <i>GitHub Scope</i> that you selected when creating
this project.
</li>
<li>
{'Modify your '}
<a
href="https://payloadcms.com/docs/configuration/collections"
rel="noopener noreferrer"
target="_blank"
>
collections
</a>
{' and add more '}
<a
href="https://payloadcms.com/docs/fields/overview"
rel="noopener noreferrer"
target="_blank"
>
fields
</a>
{' as needed. If you are new to Payload, we also recommend you check out the '}
<a
href="https://payloadcms.com/docs/getting-started/what-is-payload"
rel="noopener noreferrer"
target="_blank"
>
Getting Started
</a>
{' docs.'}
</li>
<li>
Commit and push your changes to the repository to trigger a redeployment of your project.
</li>
</ul>
{'Pro Tip: This block is a '}
<a
href="https://payloadcms.com/docs/custom-components/overview"
rel="noopener noreferrer"
target="_blank"
>
custom component
</a>
, you can remove it at any time by updating your <strong>payload.config</strong>.
</div>
)
}
export default BeforeDashboard

View File

@@ -0,0 +1,14 @@
import React from 'react'
const BeforeLogin: React.FC = () => {
return (
<div>
<p>
<b>Welcome to your dashboard!</b>
{' This is where site admins will log in to manage your website.'}
</p>
</div>
)
}
export default BeforeLogin

View File

@@ -0,0 +1,84 @@
'use client'
import { cn } from '@/utilities/ui'
import useClickableCard from '@/utilities/useClickableCard'
import Link from 'next/link'
import React, { Fragment } from 'react'
import type { Post } from '@/payload-types'
import { Media } from '@/components/Media'
export type CardPostData = Pick<Post, 'slug' | 'categories' | 'meta' | 'title'>
export const Card: React.FC<{
alignItems?: 'center'
className?: string
doc?: CardPostData
relationTo?: 'posts'
showCategories?: boolean
title?: string
}> = (props) => {
const { card, link } = useClickableCard({})
const { className, doc, relationTo, showCategories, title: titleFromProps } = props
const { slug, categories, meta, title } = doc || {}
const { description, image: metaImage } = meta || {}
const hasCategories = categories && Array.isArray(categories) && categories.length > 0
const titleToUse = titleFromProps || title
const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space
const href = `/${relationTo}/${slug}`
return (
<article
className={cn(
'border border-border rounded-lg overflow-hidden bg-card hover:cursor-pointer',
className,
)}
ref={card.ref}
>
<div className="relative w-full ">
{!metaImage && <div className="">No image</div>}
{metaImage && typeof metaImage !== 'string' && <Media resource={metaImage} size="33vw" />}
</div>
<div className="p-4">
{showCategories && hasCategories && (
<div className="uppercase text-sm mb-4">
{showCategories && hasCategories && (
<div>
{categories?.map((category, index) => {
if (typeof category === 'object') {
const { title: titleFromCategory } = category
const categoryTitle = titleFromCategory || 'Untitled category'
const isLast = index === categories.length - 1
return (
<Fragment key={index}>
{categoryTitle}
{!isLast && <Fragment>, &nbsp;</Fragment>}
</Fragment>
)
}
return null
})}
</div>
)}
</div>
)}
{titleToUse && (
<div className="prose">
<h3>
<Link className="not-prose" href={href} ref={link.ref}>
{titleToUse}
</Link>
</h3>
</div>
)}
{description && <div className="mt-2">{description && <p>{sanitizedDescription}</p>}</div>}
</div>
</article>
)
}

View File

@@ -0,0 +1,32 @@
import { cn } from '@/utilities/ui'
import React from 'react'
import { Card, CardPostData } from '@/components/Card'
export type Props = {
posts: CardPostData[]
}
export const CollectionArchive: React.FC<Props> = (props) => {
const { posts } = props
return (
<div className={cn('container')}>
<div>
<div className="grid grid-cols-4 sm:grid-cols-8 lg:grid-cols-12 gap-y-4 gap-x-4 lg:gap-y-8 lg:gap-x-8 xl:gap-x-8">
{posts?.map((result, index) => {
if (typeof result === 'object' && result !== null) {
return (
<div className="col-span-4" key={index}>
<Card className="h-full" doc={result} relationTo="posts" showCategories />
</div>
)
}
return null
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { Button, type ButtonProps } from '@/components/ui/button'
import { cn } from '@/utilities/ui'
import Link from 'next/link'
import React from 'react'
import type { Page, Post } from '@/payload-types'
type CMSLinkType = {
appearance?: 'inline' | ButtonProps['variant']
children?: React.ReactNode
className?: string
label?: string | null
newTab?: boolean | null
reference?: {
relationTo: 'pages' | 'posts'
value: Page | Post | string | number
} | null
size?: ButtonProps['size'] | null
type?: 'custom' | 'reference' | null
url?: string | null
}
export const CMSLink: React.FC<CMSLinkType> = (props) => {
const {
type,
appearance = 'inline',
children,
className,
label,
newTab,
reference,
size: sizeFromProps,
url,
} = props
const href =
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${
reference.value.slug
}`
: url
if (!href) return null
const size = appearance === 'link' ? 'clear' : sizeFromProps
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
/* Ensure we don't break any styles set by richText */
if (appearance === 'inline') {
return (
<Link className={cn(className)} href={href || url || ''} {...newTabProps}>
{label && label}
{children && children}
</Link>
)
}
return (
<Button asChild className={className} size={size} variant={appearance}>
<Link className={cn(className)} href={href || url || ''} {...newTabProps}>
{label && label}
{children && children}
</Link>
</Button>
)
}

View File

@@ -0,0 +1,10 @@
'use client'
import { getClientSideURL } from '@/utilities/getURL'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation'
import React from 'react'
export const LivePreviewListener: React.FC = () => {
const router = useRouter()
return <PayloadLivePreview refresh={router.refresh} serverURL={getClientSideURL()} />
}

View File

@@ -0,0 +1,29 @@
import clsx from 'clsx'
import React from 'react'
interface Props {
className?: string
loading?: 'lazy' | 'eager'
priority?: 'auto' | 'high' | 'low'
}
export const Logo = (props: Props) => {
const { loading: loadingFromProps, priority: priorityFromProps, className } = props
const loading = loadingFromProps || 'lazy'
const priority = priorityFromProps || 'low'
return (
/* eslint-disable @next/next/no-img-element */
<img
alt="Payload Logo"
width={193}
height={34}
loading={loading}
fetchPriority={priority}
decoding="async"
className={clsx('max-w-[9.375rem] w-full h-[34px]', className)}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
/>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import type { StaticImageData } from 'next/image'
import { cn } from '@/utilities/ui'
import NextImage from 'next/image'
import React from 'react'
import type { Props as MediaProps } from '../types'
import { cssVariables } from '@/cssVariables'
import { getMediaUrl } from '@/utilities/getMediaUrl'
const { breakpoints } = cssVariables
// A base64 encoded image to use as a placeholder while the image is loading
const placeholderBlur =
'data:image/png;base64,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'
export const ImageMedia: React.FC<MediaProps> = (props) => {
const {
alt: altFromProps,
fill,
pictureClassName,
imgClassName,
priority,
resource,
size: sizeFromProps,
src: srcFromProps,
loading: loadingFromProps,
} = props
let width: number | undefined
let height: number | undefined
let alt = altFromProps
let src: StaticImageData | string = srcFromProps || ''
if (!src && resource && typeof resource === 'object') {
const { alt: altFromResource, height: fullHeight, url, width: fullWidth } = resource
width = fullWidth!
height = fullHeight!
alt = altFromResource || ''
const cacheTag = resource.updatedAt
src = getMediaUrl(url, cacheTag)
}
const loading = loadingFromProps || (!priority ? 'lazy' : undefined)
// NOTE: this is used by the browser to determine which image to download at different screen sizes
const sizes = sizeFromProps
? sizeFromProps
: Object.entries(breakpoints)
.map(([, value]) => `(max-width: ${value}px) ${value * 2}w`)
.join(', ')
return (
<picture className={cn(pictureClassName)}>
<NextImage
alt={alt || ''}
className={cn(imgClassName)}
fill={fill}
height={!fill ? height : undefined}
placeholder="blur"
blurDataURL={placeholderBlur}
priority={priority}
quality={100}
loading={loading}
sizes={sizes}
src={src}
width={!fill ? width : undefined}
/>
</picture>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import { cn } from '@/utilities/ui'
import React, { useEffect, useRef } from 'react'
import type { Props as MediaProps } from '../types'
import { getMediaUrl } from '@/utilities/getMediaUrl'
export const VideoMedia: React.FC<MediaProps> = (props) => {
const { onClick, resource, videoClassName } = props
const videoRef = useRef<HTMLVideoElement>(null)
// const [showFallback] = useState<boolean>()
useEffect(() => {
const { current: video } = videoRef
if (video) {
video.addEventListener('suspend', () => {
// setShowFallback(true);
// console.warn('Video was suspended, rendering fallback image.')
})
}
}, [])
if (resource && typeof resource === 'object') {
const { filename } = resource
return (
<video
autoPlay
className={cn(videoClassName)}
controls={false}
loop
muted
onClick={onClick}
playsInline
ref={videoRef}
>
<source src={getMediaUrl(`/media/${filename}`)} />
</video>
)
}
return null
}

View File

@@ -0,0 +1,25 @@
import React, { Fragment } from 'react'
import type { Props } from './types'
import { ImageMedia } from './ImageMedia'
import { VideoMedia } from './VideoMedia'
export const Media: React.FC<Props> = (props) => {
const { className, htmlElement = 'div', resource } = props
const isVideo = typeof resource === 'object' && resource?.mimeType?.includes('video')
const Tag = htmlElement || Fragment
return (
<Tag
{...(htmlElement !== null
? {
className,
}
: {})}
>
{isVideo ? <VideoMedia {...props} /> : <ImageMedia {...props} />}
</Tag>
)
}

View File

@@ -0,0 +1,22 @@
import type { StaticImageData } from 'next/image'
import type { ElementType, Ref } from 'react'
import type { Media as MediaType } from '@/payload-types'
export interface Props {
alt?: string
className?: string
fill?: boolean // for NextImage only
htmlElement?: ElementType | null
pictureClassName?: string
imgClassName?: string
onClick?: () => void
onLoad?: () => void
loading?: 'lazy' | 'eager' // for NextImage only
priority?: boolean // for NextImage only
ref?: Ref<HTMLImageElement | HTMLVideoElement | null>
resource?: MediaType | string | number | null // for Payload media
size?: string // for NextImage only
src?: StaticImageData // for static media
videoClassName?: string
}

View File

@@ -0,0 +1,57 @@
import React from 'react'
const defaultLabels = {
plural: 'Docs',
singular: 'Doc',
}
const defaultCollectionLabels = {
posts: {
plural: 'Posts',
singular: 'Post',
},
}
export const PageRange: React.FC<{
className?: string
collection?: keyof typeof defaultCollectionLabels
collectionLabels?: {
plural?: string
singular?: string
}
currentPage?: number
limit?: number
totalDocs?: number
}> = (props) => {
const {
className,
collection,
collectionLabels: collectionLabelsFromProps,
currentPage,
limit,
totalDocs,
} = props
let indexStart = (currentPage ? currentPage - 1 : 1) * (limit || 1) + 1
if (totalDocs && indexStart > totalDocs) indexStart = 0
let indexEnd = (currentPage || 1) * (limit || 1)
if (totalDocs && indexEnd > totalDocs) indexEnd = totalDocs
const { plural, singular } =
collectionLabelsFromProps ||
(collection ? defaultCollectionLabels[collection] : undefined) ||
defaultLabels ||
{}
return (
<div className={[className, 'font-semibold'].filter(Boolean).join(' ')}>
{(typeof totalDocs === 'undefined' || totalDocs === 0) && 'Search produced no results.'}
{typeof totalDocs !== 'undefined' &&
totalDocs > 0 &&
`Showing ${indexStart}${indexStart > 0 ? ` - ${indexEnd}` : ''} of ${totalDocs} ${
totalDocs > 1 ? plural : singular
}`}
</div>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import {
Pagination as PaginationComponent,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { cn } from '@/utilities/ui'
import { useRouter } from 'next/navigation'
import React from 'react'
export const Pagination: React.FC<{
className?: string
page: number
totalPages: number
}> = (props) => {
const router = useRouter()
const { className, page, totalPages } = props
const hasNextPage = page < totalPages
const hasPrevPage = page > 1
const hasExtraPrevPages = page - 1 > 1
const hasExtraNextPages = page + 1 < totalPages
return (
<div className={cn('my-12', className)}>
<PaginationComponent>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={!hasPrevPage}
onClick={() => {
router.push(`/posts/page/${page - 1}`)
}}
/>
</PaginationItem>
{hasExtraPrevPages && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
{hasPrevPage && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(`/posts/page/${page - 1}`)
}}
>
{page - 1}
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
isActive
onClick={() => {
router.push(`/posts/page/${page}`)
}}
>
{page}
</PaginationLink>
</PaginationItem>
{hasNextPage && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(`/posts/page/${page + 1}`)
}}
>
{page + 1}
</PaginationLink>
</PaginationItem>
)}
{hasExtraNextPages && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
disabled={!hasNextPage}
onClick={() => {
router.push(`/posts/page/${page + 1}`)
}}
/>
</PaginationItem>
</PaginationContent>
</PaginationComponent>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import type React from 'react'
import type { Page, Post } from '@/payload-types'
import { getCachedDocument } from '@/utilities/getDocument'
import { getCachedRedirects } from '@/utilities/getRedirects'
import { notFound, redirect } from 'next/navigation'
interface Props {
disableNotFound?: boolean
url: string
}
/* This component helps us with SSR based dynamic redirects */
export const PayloadRedirects: React.FC<Props> = async ({ disableNotFound, url }) => {
const redirects = await getCachedRedirects()()
const redirectItem = redirects.find((redirect) => redirect.from === url)
if (redirectItem) {
if (redirectItem.to?.url) {
redirect(redirectItem.to.url)
}
let redirectUrl: string
if (typeof redirectItem.to?.reference?.value === 'string') {
const collection = redirectItem.to?.reference?.relationTo
const id = redirectItem.to?.reference?.value
const document = (await getCachedDocument(collection, id)()) as Page | Post
redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${
document?.slug
}`
} else {
redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${
typeof redirectItem.to?.reference?.value === 'object'
? redirectItem.to?.reference?.value?.slug
: ''
}`
}
if (redirectUrl) redirect(redirectUrl)
}
if (disableNotFound) return null
notFound()
}

View File

@@ -0,0 +1,81 @@
import { MediaBlock } from '@/blocks/MediaBlock/Component'
import {
DefaultNodeTypes,
SerializedBlockNode,
SerializedLinkNode,
type DefaultTypedEditorState,
} from '@payloadcms/richtext-lexical'
import {
JSXConvertersFunction,
LinkJSXConverter,
RichText as ConvertRichText,
} from '@payloadcms/richtext-lexical/react'
import { CodeBlock, CodeBlockProps } from '@/blocks/Code/Component'
import type {
BannerBlock as BannerBlockProps,
CallToActionBlock as CTABlockProps,
MediaBlock as MediaBlockProps,
} from '@/payload-types'
import { BannerBlock } from '@/blocks/Banner/Component'
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
import { cn } from '@/utilities/ui'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<CTABlockProps | MediaBlockProps | BannerBlockProps | CodeBlockProps>
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
const { value, relationTo } = linkNode.fields.doc!
if (typeof value !== 'object') {
throw new Error('Expected value to be an object')
}
const slug = value.slug
return relationTo === 'posts' ? `/posts/${slug}` : `/${slug}`
}
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
...LinkJSXConverter({ internalDocToHref }),
blocks: {
banner: ({ node }) => <BannerBlock className="col-start-2 mb-4" {...node.fields} />,
mediaBlock: ({ node }) => (
<MediaBlock
className="col-start-1 col-span-3"
imgClassName="m-0"
{...node.fields}
captionClassName="mx-auto max-w-[48rem]"
enableGutter={false}
disableInnerContainer={true}
/>
),
code: ({ node }) => <CodeBlock className="col-start-2" {...node.fields} />,
cta: ({ node }) => <CallToActionBlock {...node.fields} />,
},
})
type Props = {
data: DefaultTypedEditorState
enableGutter?: boolean
enableProse?: boolean
} & React.HTMLAttributes<HTMLDivElement>
export default function RichText(props: Props) {
const { className, enableProse = true, enableGutter = true, ...rest } = props
return (
<ConvertRichText
converters={jsxConverters}
className={cn(
'payload-richtext',
{
container: enableGutter,
'max-w-none': !enableGutter,
'mx-auto prose md:prose-md dark:prose-invert': enableProse,
},
className,
)}
{...rest}
/>
)
}

View File

@@ -0,0 +1,52 @@
import { cn } from '@/utilities/ui'
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
defaultVariants: {
size: 'default',
variant: 'default',
},
variants: {
size: {
clear: '',
default: 'h-10 px-4 py-2',
icon: 'h-10 w-10',
lg: 'h-11 rounded px-8',
sm: 'h-9 rounded px-3',
},
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-card hover:text-accent-foreground',
link: 'text-primary items-start justify-start underline-offset-4 hover:underline',
outline: 'border border-border bg-background hover:bg-card hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
},
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
ref?: React.Ref<HTMLButtonElement>
}
const Button: React.FC<ButtonProps> = ({
asChild = false,
className,
size,
variant,
ref,
...props
}) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ className, size, variant }))} ref={ref} {...props} />
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,48 @@
import { cn } from '@/utilities/ui'
import * as React from 'react'
const Card: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
ref={ref}
{...props}
/>
)
const CardHeader: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div className={cn('flex flex-col space-y-1.5 p-6', className)} ref={ref} {...props} />
)
const CardTitle: React.FC<
{ ref?: React.Ref<HTMLHeadingElement> } & React.HTMLAttributes<HTMLHeadingElement>
> = ({ className, ref, ...props }) => (
<h3
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
ref={ref}
{...props}
/>
)
const CardDescription: React.FC<
{ ref?: React.Ref<HTMLParagraphElement> } & React.HTMLAttributes<HTMLParagraphElement>
> = ({ className, ref, ...props }) => (
<p className={cn('text-sm text-muted-foreground', className)} ref={ref} {...props} />
)
const CardContent: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div className={cn('p-6 pt-0', className)} ref={ref} {...props} />
)
const CardFooter: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div className={cn('flex items-center p-6 pt-0', className)} ref={ref} {...props} />
)
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

View File

@@ -0,0 +1,27 @@
'use client'
import { cn } from '@/utilities/ui'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import * as React from 'react'
const Checkbox: React.FC<
{
ref?: React.Ref<HTMLButtonElement>
} & React.ComponentProps<typeof CheckboxPrimitive.Root>
> = ({ className, ref, ...props }) => (
<CheckboxPrimitive.Root
className={cn(
'peer h-4 w-4 shrink-0 rounded border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
ref={ref}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
export { Checkbox }

View File

@@ -0,0 +1,22 @@
import { cn } from '@/utilities/ui'
import * as React from 'react'
const Input: React.FC<
{
ref?: React.Ref<HTMLInputElement>
} & React.InputHTMLAttributes<HTMLInputElement>
> = ({ type, className, ref, ...props }) => {
return (
<input
className={cn(
'flex h-10 w-full rounded border border-border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
type={type}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,19 @@
'use client'
import { cn } from '@/utilities/ui'
import * as LabelPrimitive from '@radix-ui/react-label'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
)
const Label: React.FC<
{ ref?: React.Ref<HTMLLabelElement> } & React.ComponentProps<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
> = ({ className, ref, ...props }) => (
<LabelPrimitive.Root className={cn(labelVariants(), className)} ref={ref} {...props} />
)
export { Label }

View File

@@ -0,0 +1,92 @@
import type { ButtonProps } from '@/components/ui/button'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/utilities/ui'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import * as React from 'react'
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
role="navigation"
{...props}
/>
)
const PaginationContent: React.FC<
{ ref?: React.Ref<HTMLUListElement> } & React.HTMLAttributes<HTMLUListElement>
> = ({ className, ref, ...props }) => (
<ul className={cn('flex flex-row items-center gap-1', className)} ref={ref} {...props} />
)
const PaginationItem: React.FC<
{ ref?: React.Ref<HTMLLIElement> } & React.HTMLAttributes<HTMLLIElement>
> = ({ className, ref, ...props }) => <li className={cn('', className)} ref={ref} {...props} />
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'button'>
const PaginationLink = ({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) => (
<button
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
size,
variant: isActive ? 'outline' : 'ghost',
}),
className,
)}
{...props}
/>
)
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
className={cn('gap-1 pl-2.5', className)}
size="default"
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
className={cn('gap-1 pr-2.5', className)}
size="default"
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -0,0 +1,144 @@
'use client'
import { cn } from '@/utilities/ui'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import * as React from 'react'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger: React.FC<
{ ref?: React.Ref<HTMLButtonElement> } & React.ComponentProps<typeof SelectPrimitive.Trigger>
> = ({ children, className, ref, ...props }) => (
<SelectPrimitive.Trigger
className={cn(
'flex h-10 w-full items-center justify-between rounded border border-input bg-background px-3 py-2 text-inherit ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
ref={ref}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
const SelectScrollUpButton: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>
> = ({ className, ref, ...props }) => (
<SelectPrimitive.ScrollUpButton
className={cn('flex cursor-default items-center justify-center py-1', className)}
ref={ref}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
)
const SelectScrollDownButton: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.ComponentProps<
typeof SelectPrimitive.ScrollDownButton
>
> = ({ className, ref, ...props }) => (
<SelectPrimitive.ScrollDownButton
className={cn('flex cursor-default items-center justify-center py-1', className)}
ref={ref}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
)
const SelectContent: React.FC<
{
ref?: React.Ref<HTMLDivElement>
} & React.ComponentProps<typeof SelectPrimitive.Content>
> = ({ children, className, position = 'popper', ref, ...props }) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded border bg-card text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-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',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
ref={ref}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
const SelectLabel: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.ComponentProps<typeof SelectPrimitive.Label>
> = ({ className, ref, ...props }) => (
<SelectPrimitive.Label
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
ref={ref}
{...props}
/>
)
const SelectItem: React.FC<
{ ref?: React.Ref<HTMLDivElement>; value: string } & React.ComponentProps<
typeof SelectPrimitive.Item
>
> = ({ children, className, ref, ...props }) => (
<SelectPrimitive.Item
className={cn(
'relative flex w-full cursor-default select-none items-center rounded py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
ref={ref}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
const SelectSeparator: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.ComponentProps<typeof SelectPrimitive.Separator>
> = ({ className, ref, ...props }) => (
<SelectPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-muted', className)}
ref={ref}
{...props}
/>
)
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,21 @@
import { cn } from '@/utilities/ui'
import * as React from 'react'
const Textarea: React.FC<
{
ref?: React.Ref<HTMLTextAreaElement>
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>
> = ({ className, ref, ...props }) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded border border-border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
}
export { Textarea }