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:
7
apps/backend/src/components/AdminBar/index.scss
Normal file
7
apps/backend/src/components/AdminBar/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@import '~@payloadcms/ui/scss';
|
||||
|
||||
.admin-bar {
|
||||
@include small-break {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
89
apps/backend/src/components/AdminBar/index.tsx
Normal file
89
apps/backend/src/components/AdminBar/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.seedButton {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
24
apps/backend/src/components/BeforeDashboard/index.scss
Normal file
24
apps/backend/src/components/BeforeDashboard/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
74
apps/backend/src/components/BeforeDashboard/index.tsx
Normal file
74
apps/backend/src/components/BeforeDashboard/index.tsx
Normal 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'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
|
||||
14
apps/backend/src/components/BeforeLogin/index.tsx
Normal file
14
apps/backend/src/components/BeforeLogin/index.tsx
Normal 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
|
||||
84
apps/backend/src/components/Card/index.tsx
Normal file
84
apps/backend/src/components/Card/index.tsx
Normal 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>, </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>
|
||||
)
|
||||
}
|
||||
32
apps/backend/src/components/CollectionArchive/index.tsx
Normal file
32
apps/backend/src/components/CollectionArchive/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
apps/backend/src/components/Link/index.tsx
Normal file
66
apps/backend/src/components/Link/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
apps/backend/src/components/LivePreviewListener/index.tsx
Normal file
10
apps/backend/src/components/LivePreviewListener/index.tsx
Normal 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()} />
|
||||
}
|
||||
29
apps/backend/src/components/Logo/Logo.tsx
Normal file
29
apps/backend/src/components/Logo/Logo.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
77
apps/backend/src/components/Media/ImageMedia/index.tsx
Normal file
77
apps/backend/src/components/Media/ImageMedia/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
apps/backend/src/components/Media/VideoMedia/index.tsx
Normal file
46
apps/backend/src/components/Media/VideoMedia/index.tsx
Normal 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
|
||||
}
|
||||
25
apps/backend/src/components/Media/index.tsx
Normal file
25
apps/backend/src/components/Media/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
apps/backend/src/components/Media/types.ts
Normal file
22
apps/backend/src/components/Media/types.ts
Normal 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
|
||||
}
|
||||
57
apps/backend/src/components/PageRange/index.tsx
Normal file
57
apps/backend/src/components/PageRange/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
apps/backend/src/components/Pagination/index.tsx
Normal file
101
apps/backend/src/components/Pagination/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
apps/backend/src/components/PayloadRedirects/index.tsx
Normal file
48
apps/backend/src/components/PayloadRedirects/index.tsx
Normal 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()
|
||||
}
|
||||
81
apps/backend/src/components/RichText/index.tsx
Normal file
81
apps/backend/src/components/RichText/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
52
apps/backend/src/components/ui/button.tsx
Normal file
52
apps/backend/src/components/ui/button.tsx
Normal 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 }
|
||||
48
apps/backend/src/components/ui/card.tsx
Normal file
48
apps/backend/src/components/ui/card.tsx
Normal 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 }
|
||||
27
apps/backend/src/components/ui/checkbox.tsx
Normal file
27
apps/backend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
22
apps/backend/src/components/ui/input.tsx
Normal file
22
apps/backend/src/components/ui/input.tsx
Normal 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 }
|
||||
19
apps/backend/src/components/ui/label.tsx
Normal file
19
apps/backend/src/components/ui/label.tsx
Normal 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 }
|
||||
92
apps/backend/src/components/ui/pagination.tsx
Normal file
92
apps/backend/src/components/ui/pagination.tsx
Normal 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,
|
||||
}
|
||||
144
apps/backend/src/components/ui/select.tsx
Normal file
144
apps/backend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
21
apps/backend/src/components/ui/textarea.tsx
Normal file
21
apps/backend/src/components/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user