chore: migrate to payload / nextjs
This commit is contained in:
75
src/App.tsx
75
src/App.tsx
@@ -1,75 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
import ScrollToTop from '@/components/layout/ScrollToTop';
|
||||
import Home from '@/pages/Home';
|
||||
import Participation from '@/pages/Participation';
|
||||
import About from '@/pages/About';
|
||||
import Preistraeger from '@/pages/Preistraeger';
|
||||
import PreistraegerDetail from '@/pages/PreistraegerDetail';
|
||||
import Press from '@/pages/Press';
|
||||
import EventDetail from './pages/EventDetail';
|
||||
import BlogDetail from './pages/BlogDetail';
|
||||
import Contact from '@/pages/Contact';
|
||||
import FormularUpload from '@/pages/FormularUpload';
|
||||
import Netzwerk from '@/pages/Netzwerk';
|
||||
import MitgliedWerden from '@/pages/MitgliedWerden';
|
||||
import Datenschutz from '@/pages/Datenschutz';
|
||||
import Impressum from '@/pages/Impressum';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Basic loading simulation for splash
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000] bg-primary flex flex-col items-center justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 border-2 border-accent/20 border-t-accent rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-accent font-display font-bold text-2xl">BMP</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 text-white/40 uppercase tracking-[0.4em] text-[10px] font-bold animate-pulse">
|
||||
Exzellenz wird geladen...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/teilnahme" element={<Participation />} />
|
||||
<Route path="/preistraeger" element={<Preistraeger />} />
|
||||
<Route path="/preistraeger/:slug" element={<PreistraegerDetail />} />
|
||||
<Route path="/der-bmp" element={<About />} />
|
||||
<Route path="/netzwerk" element={<Netzwerk />} />
|
||||
<Route path="/presse" element={<Press />} />
|
||||
<Route path="/presse/events/:slug" element={<EventDetail />} />
|
||||
<Route path="/presse/blog/:slug" element={<BlogDetail />} />
|
||||
<Route path="/kontakt" element={<Contact />} />
|
||||
<Route path="/formular-hochladen" element={<FormularUpload />} />
|
||||
<Route path="/mitglied-werden" element={<MitgliedWerden />} />
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
{/* Fallback to home */}
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
32
src/Footer/Component.tsx
Normal file
32
src/Footer/Component.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import { ThemeSelector } from '@/providers/Theme/ThemeSelector'
|
||||
import { CMSLink } from '@/components/Link'
|
||||
import { Logo } from '@/components/Logo/Logo'
|
||||
|
||||
export async function Footer() {
|
||||
const footerData = await getCachedGlobal('footer', 1)()
|
||||
|
||||
const navItems = footerData?.navItems || []
|
||||
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border bg-black dark:bg-card text-white">
|
||||
<div className="container py-8 gap-8 flex flex-col md:flex-row md:justify-between">
|
||||
<Link className="flex items-center" href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col-reverse items-start md:flex-row gap-4 md:items-center">
|
||||
<ThemeSelector />
|
||||
<nav className="flex flex-col md:flex-row gap-4">
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink className="text-white" key={i} {...link} />
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
13
src/Footer/RowLabel.tsx
Normal file
13
src/Footer/RowLabel.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
import { Header } from '@/payload-types'
|
||||
import { RowLabelProps, useRowLabel } from '@payloadcms/ui'
|
||||
|
||||
export const RowLabel: React.FC<RowLabelProps> = () => {
|
||||
const data = useRowLabel<NonNullable<Header['navItems']>[number]>()
|
||||
|
||||
const label = data?.data?.link?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}`
|
||||
: 'Row'
|
||||
|
||||
return <div>{label}</div>
|
||||
}
|
||||
32
src/Footer/config.ts
Normal file
32
src/Footer/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateFooter } from './hooks/revalidateFooter'
|
||||
|
||||
export const Footer: GlobalConfig = {
|
||||
slug: 'footer',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'navItems',
|
||||
type: 'array',
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
maxRows: 6,
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
components: {
|
||||
RowLabel: '@/Footer/RowLabel#RowLabel',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateFooter],
|
||||
},
|
||||
}
|
||||
13
src/Footer/hooks/revalidateFooter.ts
Normal file
13
src/Footer/hooks/revalidateFooter.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateFooter: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
payload.logger.info(`Revalidating footer`)
|
||||
|
||||
revalidateTag('global_footer', 'max')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
42
src/Header/Component.client.tsx
Normal file
42
src/Header/Component.client.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
import { useHeaderTheme } from '@/providers/HeaderTheme'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import type { Header } from '@/payload-types'
|
||||
|
||||
import { Logo } from '@/components/Logo/Logo'
|
||||
import { HeaderNav } from './Nav'
|
||||
|
||||
interface HeaderClientProps {
|
||||
data: Header
|
||||
}
|
||||
|
||||
export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
|
||||
/* Storing the value in a useState to avoid hydration errors */
|
||||
const [theme, setTheme] = useState<string | null>(null)
|
||||
const { headerTheme, setHeaderTheme } = useHeaderTheme()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderTheme(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (headerTheme && headerTheme !== theme) setTheme(headerTheme)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [headerTheme])
|
||||
|
||||
return (
|
||||
<header className="container relative z-20 " {...(theme ? { 'data-theme': theme } : {})}>
|
||||
<div className="py-8 flex justify-between">
|
||||
<Link href="/">
|
||||
<Logo loading="eager" priority="high" className="invert dark:invert-0" />
|
||||
</Link>
|
||||
<HeaderNav data={data} />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
9
src/Header/Component.tsx
Normal file
9
src/Header/Component.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { HeaderClient } from './Component.client'
|
||||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
import React from 'react'
|
||||
|
||||
export async function Header() {
|
||||
const headerData = await getCachedGlobal('header', 1)()
|
||||
|
||||
return <HeaderClient data={headerData} />
|
||||
}
|
||||
25
src/Header/Nav/index.tsx
Normal file
25
src/Header/Nav/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { Header as HeaderType } from '@/payload-types'
|
||||
|
||||
import { CMSLink } from '@/components/Link'
|
||||
import Link from 'next/link'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
||||
const navItems = data?.navItems || []
|
||||
|
||||
return (
|
||||
<nav className="flex gap-3 items-center">
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink key={i} {...link} appearance="link" />
|
||||
})}
|
||||
<Link href="/search">
|
||||
<span className="sr-only">Search</span>
|
||||
<SearchIcon className="w-5 text-primary" />
|
||||
</Link>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
13
src/Header/RowLabel.tsx
Normal file
13
src/Header/RowLabel.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
import { Header } from '@/payload-types'
|
||||
import { RowLabelProps, useRowLabel } from '@payloadcms/ui'
|
||||
|
||||
export const RowLabel: React.FC<RowLabelProps> = () => {
|
||||
const data = useRowLabel<NonNullable<Header['navItems']>[number]>()
|
||||
|
||||
const label = data?.data?.link?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}`
|
||||
: 'Row'
|
||||
|
||||
return <div>{label}</div>
|
||||
}
|
||||
32
src/Header/config.ts
Normal file
32
src/Header/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateHeader } from './hooks/revalidateHeader'
|
||||
|
||||
export const Header: GlobalConfig = {
|
||||
slug: 'header',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'navItems',
|
||||
type: 'array',
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
maxRows: 6,
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
components: {
|
||||
RowLabel: '@/Header/RowLabel#RowLabel',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateHeader],
|
||||
},
|
||||
}
|
||||
13
src/Header/hooks/revalidateHeader.ts
Normal file
13
src/Header/hooks/revalidateHeader.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
payload.logger.info(`Revalidating header`)
|
||||
|
||||
revalidateTag('global_header', 'max')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
3
src/access/anyone.ts
Normal file
3
src/access/anyone.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const anyone: Access = () => true
|
||||
9
src/access/authenticated.ts
Normal file
9
src/access/authenticated.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { AccessArgs } from 'payload'
|
||||
|
||||
import type { User } from '@/payload-types'
|
||||
|
||||
type isAuthenticated = (args: AccessArgs<User>) => boolean
|
||||
|
||||
export const authenticated: isAuthenticated = ({ req: { user } }) => {
|
||||
return Boolean(user)
|
||||
}
|
||||
13
src/access/authenticatedOrPublished.ts
Normal file
13
src/access/authenticatedOrPublished.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
|
||||
if (user) {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
}
|
||||
}
|
||||
68
src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts
Normal file
68
src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getServerSideSitemap } from 'next-sitemap'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
const getPagesSitemap = unstable_cache(
|
||||
async () => {
|
||||
const payload = await getPayload({ config })
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SERVER_URL ||
|
||||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
|
||||
'https://example.com'
|
||||
|
||||
const results = await payload.find({
|
||||
collection: 'pages',
|
||||
overrideAccess: false,
|
||||
draft: false,
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
pagination: false,
|
||||
where: {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const dateFallback = new Date().toISOString()
|
||||
|
||||
const defaultSitemap = [
|
||||
{
|
||||
loc: `${SITE_URL}/search`,
|
||||
lastmod: dateFallback,
|
||||
},
|
||||
{
|
||||
loc: `${SITE_URL}/posts`,
|
||||
lastmod: dateFallback,
|
||||
},
|
||||
]
|
||||
|
||||
const sitemap = results.docs
|
||||
? results.docs
|
||||
.filter((page) => Boolean(page?.slug))
|
||||
.map((page) => {
|
||||
return {
|
||||
loc: page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`,
|
||||
lastmod: page.updatedAt || dateFallback,
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
return [...defaultSitemap, ...sitemap]
|
||||
},
|
||||
['pages-sitemap'],
|
||||
{
|
||||
tags: ['pages-sitemap'],
|
||||
},
|
||||
)
|
||||
|
||||
export async function GET() {
|
||||
const sitemap = await getPagesSitemap()
|
||||
|
||||
return getServerSideSitemap(sitemap)
|
||||
}
|
||||
55
src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts
Normal file
55
src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getServerSideSitemap } from 'next-sitemap'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
const getPostsSitemap = unstable_cache(
|
||||
async () => {
|
||||
const payload = await getPayload({ config })
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SERVER_URL ||
|
||||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
|
||||
'https://example.com'
|
||||
|
||||
const results = await payload.find({
|
||||
collection: 'posts',
|
||||
overrideAccess: false,
|
||||
draft: false,
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
pagination: false,
|
||||
where: {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const dateFallback = new Date().toISOString()
|
||||
|
||||
const sitemap = results.docs
|
||||
? results.docs
|
||||
.filter((post) => Boolean(post?.slug))
|
||||
.map((post) => ({
|
||||
loc: `${SITE_URL}/posts/${post?.slug}`,
|
||||
lastmod: post.updatedAt || dateFallback,
|
||||
}))
|
||||
: []
|
||||
|
||||
return sitemap
|
||||
},
|
||||
['posts-sitemap'],
|
||||
{
|
||||
tags: ['posts-sitemap'],
|
||||
},
|
||||
)
|
||||
|
||||
export async function GET() {
|
||||
const sitemap = await getPostsSitemap()
|
||||
|
||||
return getServerSideSitemap(sitemap)
|
||||
}
|
||||
12
src/app/(frontend)/[[...slug]]/page.tsx
Normal file
12
src/app/(frontend)/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import NextApp from '@/spa/NextApp'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ slug?: string[] }>
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Args) {
|
||||
const { slug = [] } = await params
|
||||
const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/'
|
||||
|
||||
return <NextApp pathname={pathname} />
|
||||
}
|
||||
124
src/app/(frontend)/globals.css
Normal file
124
src/app/(frontend)/globals.css
Normal file
@@ -0,0 +1,124 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-display: "IBM Plex Sans", sans-serif;
|
||||
--font-body: "Inter", sans-serif;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-md: calc(0.5rem - 2px);
|
||||
--radius-sm: calc(0.5rem - 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #E4E2E3;
|
||||
--foreground: #020617;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #020617;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #020617;
|
||||
--primary: #03093A;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f8fafc;
|
||||
--secondary-foreground: #03093A;
|
||||
--muted: #f1f5f9;
|
||||
--muted-foreground: #64748b;
|
||||
--accent: #EFBF04;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ef4444;
|
||||
--border: #e2e8f0;
|
||||
--input: #e2e8f0;
|
||||
--ring: #03093A;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #020617;
|
||||
--foreground: #f8fafc;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f8fafc;
|
||||
--popover: #020617;
|
||||
--popover-foreground: #f8fafc;
|
||||
--primary: #03093A;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #EFBF04;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #7f1d1d;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--input: rgba(255, 255, 255, 0.15);
|
||||
--ring: #94a3b8;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-body;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #EFBF04;
|
||||
color: #03093A;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-display font-bold;
|
||||
}
|
||||
|
||||
blockquote, q,
|
||||
.quote, [class*="italic"] {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
label,
|
||||
.label {
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
}
|
||||
|
||||
input, textarea, select, button {
|
||||
font-family: "Inter", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.6s ease-out;
|
||||
}
|
||||
}
|
||||
21
src/app/(frontend)/layout.tsx
Normal file
21
src/app/(frontend)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from 'next'
|
||||
import React from 'react'
|
||||
|
||||
import './globals.css'
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<link href="/favicon.ico" rel="icon" sizes="32x32" />
|
||||
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bayerischer Mittelstandspreis 2026',
|
||||
description: 'Der Preis des Bayerischen Mittelstands',
|
||||
}
|
||||
7
src/app/(frontend)/next/exit-preview/route.ts
Normal file
7
src/app/(frontend)/next/exit-preview/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const draft = await draftMode()
|
||||
draft.disable()
|
||||
return new Response('Draft mode is disabled')
|
||||
}
|
||||
59
src/app/(frontend)/next/preview/route.ts
Normal file
59
src/app/(frontend)/next/preview/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { draftMode } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export type PreviewSearchParams = {
|
||||
path: string
|
||||
previewSecret: string
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest): Promise<Response> {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
const path = searchParams.get('path')
|
||||
const previewSecret = searchParams.get('previewSecret')
|
||||
|
||||
if (previewSecret !== process.env.PREVIEW_SECRET) {
|
||||
return new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return new Response('Insufficient search params', { status: 404 })
|
||||
}
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
return new Response('This endpoint can only be used for relative previews', { status: 500 })
|
||||
}
|
||||
|
||||
let user
|
||||
|
||||
try {
|
||||
user = await payload.auth({
|
||||
req: req as unknown as PayloadRequest,
|
||||
headers: req.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
payload.logger.error({ err: error }, 'Error verifying token for live preview')
|
||||
return new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
const draft = await draftMode()
|
||||
|
||||
if (!user) {
|
||||
draft.disable()
|
||||
return new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
// You can add additional checks here to see if the user is allowed to preview this page
|
||||
|
||||
draft.enable()
|
||||
|
||||
redirect(path)
|
||||
}
|
||||
31
src/app/(frontend)/next/seed/route.ts
Normal file
31
src/app/(frontend)/next/seed/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createLocalReq, getPayload } from 'payload'
|
||||
import { seed } from '@/endpoints/seed'
|
||||
import config from '@payload-config'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export const maxDuration = 60 // This function can run for a maximum of 60 seconds
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
const payload = await getPayload({ config })
|
||||
const requestHeaders = await headers()
|
||||
|
||||
// Authenticate by passing request headers
|
||||
const { user } = await payload.auth({ headers: requestHeaders })
|
||||
|
||||
if (!user) {
|
||||
return new Response('Action forbidden.', { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a Payload request object to pass to the Local API for transactions
|
||||
// At this point you should pass in a user, locale, and any other context you need for the Local API
|
||||
const payloadReq = await createLocalReq({ user }, payload)
|
||||
|
||||
await seed({ payload, req: payloadReq })
|
||||
|
||||
return Response.json({ success: true })
|
||||
} catch (e) {
|
||||
payload.logger.error({ err: e, message: 'Error seeding data' })
|
||||
return new Response('Error seeding data.', { status: 500 })
|
||||
}
|
||||
}
|
||||
18
src/app/(frontend)/not-found.tsx
Normal file
18
src/app/(frontend)/not-found.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="container py-28">
|
||||
<div className="prose max-w-none">
|
||||
<h1 style={{ marginBottom: 0 }}>404</h1>
|
||||
<p className="mb-4">This page could not be found.</p>
|
||||
</div>
|
||||
<Button asChild variant="default">
|
||||
<Link href="/">Go home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
24
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params, searchParams, importMap })
|
||||
|
||||
export default NotFound
|
||||
24
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
24
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap })
|
||||
|
||||
export default Page
|
||||
80
src/app/(payload)/admin/importMap.js
Normal file
80
src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { SlugField as SlugField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { FolderTableCell as FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
import { FolderField as FolderField_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { FolderTypeField as FolderTypeField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client'
|
||||
import { RowLabel as RowLabel_ec255a65fa6fa8d1faeb09cf35284224 } from '@/Header/RowLabel'
|
||||
import { RowLabel as RowLabel_1f6ff6ff633e3695d348f4f3c58f1466 } from '@/Footer/RowLabel'
|
||||
import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/components/BeforeDashboard'
|
||||
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
|
||||
FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/plugin-seo/client#OverviewComponent':
|
||||
OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaTitleComponent':
|
||||
MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaImageComponent':
|
||||
MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaDescriptionComponent':
|
||||
MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#PreviewComponent':
|
||||
PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/next/client#SlugField': SlugField_2b8867833a34864a02ddf429b0728a40,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/next/rsc#FolderTableCell': FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
'@payloadcms/next/rsc#FolderField': FolderField_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
'@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@payloadcms/next/client#FolderTypeField': FolderTypeField_2b8867833a34864a02ddf429b0728a40,
|
||||
'@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||
'@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||
'@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6,
|
||||
'@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e,
|
||||
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
}
|
||||
20
src/app/(payload)/api/[...slug]/route.ts
Normal file
20
src/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
|
||||
export const PUT = REST_PUT(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
7
src/app/(payload)/api/graphql-playground/route.ts
Normal file
7
src/app/(payload)/api/graphql-playground/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
8
src/app/(payload)/api/graphql/route.ts
Normal file
8
src/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
0
src/app/(payload)/custom.scss
Normal file
0
src/app/(payload)/custom.scss
Normal file
31
src/app/(payload)/layout.tsx
Normal file
31
src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import type { ServerFunctionClient } from 'payload'
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||
import React from 'react'
|
||||
|
||||
import { importMap } from './admin/importMap.js'
|
||||
import './custom.scss'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server'
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
65
src/blocks/ArchiveBlock/Component.tsx
Normal file
65
src/blocks/ArchiveBlock/Component.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Post, ArchiveBlock as ArchiveBlockProps } from '@/payload-types'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import { CollectionArchive } from '@/components/CollectionArchive'
|
||||
|
||||
export const ArchiveBlock: React.FC<
|
||||
ArchiveBlockProps & {
|
||||
id?: string
|
||||
}
|
||||
> = async (props) => {
|
||||
const { id, categories, introContent, limit: limitFromProps, populateBy, selectedDocs } = props
|
||||
|
||||
const limit = limitFromProps || 3
|
||||
|
||||
let posts: Post[] = []
|
||||
|
||||
if (populateBy === 'collection') {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const flattenedCategories = categories?.map((category) => {
|
||||
if (typeof category === 'object') return category.id
|
||||
else return category
|
||||
})
|
||||
|
||||
const fetchedPosts = await payload.find({
|
||||
collection: 'posts',
|
||||
depth: 1,
|
||||
limit,
|
||||
...(flattenedCategories && flattenedCategories.length > 0
|
||||
? {
|
||||
where: {
|
||||
categories: {
|
||||
in: flattenedCategories,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
posts = fetchedPosts.docs
|
||||
} else {
|
||||
if (selectedDocs?.length) {
|
||||
const filteredSelectedPosts = selectedDocs.map((post) => {
|
||||
if (typeof post.value === 'object') return post.value
|
||||
}) as Post[]
|
||||
|
||||
posts = filteredSelectedPosts
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-16" id={`block-${id}`}>
|
||||
{introContent && (
|
||||
<div className="container mb-16">
|
||||
<RichText className="ms-0 max-w-[48rem]" data={introContent} enableGutter={false} />
|
||||
</div>
|
||||
)}
|
||||
<CollectionArchive posts={posts} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/blocks/ArchiveBlock/config.ts
Normal file
94
src/blocks/ArchiveBlock/config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Archive: Block = {
|
||||
slug: 'archive',
|
||||
interfaceName: 'ArchiveBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'introContent',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: 'Intro Content',
|
||||
},
|
||||
{
|
||||
name: 'populateBy',
|
||||
type: 'select',
|
||||
defaultValue: 'collection',
|
||||
options: [
|
||||
{
|
||||
label: 'Collection',
|
||||
value: 'collection',
|
||||
},
|
||||
{
|
||||
label: 'Individual Selection',
|
||||
value: 'selection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'relationTo',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
},
|
||||
defaultValue: 'posts',
|
||||
label: 'Collections To Show',
|
||||
options: [
|
||||
{
|
||||
label: 'Posts',
|
||||
value: 'posts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
},
|
||||
hasMany: true,
|
||||
label: 'Categories To Show',
|
||||
relationTo: 'categories',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
step: 1,
|
||||
},
|
||||
defaultValue: 10,
|
||||
label: 'Limit',
|
||||
},
|
||||
{
|
||||
name: 'selectedDocs',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'selection',
|
||||
},
|
||||
hasMany: true,
|
||||
label: 'Selection',
|
||||
relationTo: ['posts'],
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
plural: 'Archives',
|
||||
singular: 'Archive',
|
||||
},
|
||||
}
|
||||
26
src/blocks/Banner/Component.tsx
Normal file
26
src/blocks/Banner/Component.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { BannerBlock as BannerBlockProps } from 'src/payload-types'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
} & BannerBlockProps
|
||||
|
||||
export const BannerBlock: React.FC<Props> = ({ className, content, style }) => {
|
||||
return (
|
||||
<div className={cn('mx-auto my-8 w-full', className)}>
|
||||
<div
|
||||
className={cn('border py-3 px-6 flex items-center rounded', {
|
||||
'border-border bg-card': style === 'info',
|
||||
'border-error bg-error/30': style === 'error',
|
||||
'border-success bg-success/30': style === 'success',
|
||||
'border-warning bg-warning/30': style === 'warning',
|
||||
})}
|
||||
>
|
||||
<RichText data={content} enableGutter={false} enableProse={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/blocks/Banner/config.ts
Normal file
37
src/blocks/Banner/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Banner: Block = {
|
||||
slug: 'banner',
|
||||
fields: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'select',
|
||||
defaultValue: 'info',
|
||||
options: [
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warning', value: 'warning' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Success', value: 'success' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
interfaceName: 'BannerBlock',
|
||||
}
|
||||
23
src/blocks/CallToAction/Component.tsx
Normal file
23
src/blocks/CallToAction/Component.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { CallToActionBlock as CTABlockProps } from '@/payload-types'
|
||||
|
||||
import RichText from '@/components/RichText'
|
||||
import { CMSLink } from '@/components/Link'
|
||||
|
||||
export const CallToActionBlock: React.FC<CTABlockProps> = ({ links, richText }) => {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="bg-card rounded border-border border p-4 flex flex-col gap-8 md:flex-row md:justify-between md:items-center">
|
||||
<div className="max-w-[48rem] flex items-center">
|
||||
{richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
{(links || []).map(({ link }, i) => {
|
||||
return <CMSLink key={i} size="lg" {...link} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/blocks/CallToAction/config.ts
Normal file
42
src/blocks/CallToAction/config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { linkGroup } from '../../fields/linkGroup'
|
||||
|
||||
export const CallToAction: Block = {
|
||||
slug: 'cta',
|
||||
interfaceName: 'CallToActionBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
},
|
||||
linkGroup({
|
||||
appearances: ['default', 'outline'],
|
||||
overrides: {
|
||||
maxRows: 2,
|
||||
},
|
||||
}),
|
||||
],
|
||||
labels: {
|
||||
plural: 'Calls to Action',
|
||||
singular: 'Call to Action',
|
||||
},
|
||||
}
|
||||
33
src/blocks/Code/Component.client.tsx
Normal file
33
src/blocks/Code/Component.client.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
import { Highlight, themes } from 'prism-react-renderer'
|
||||
import React from 'react'
|
||||
import { CopyButton } from './CopyButton'
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export const Code: React.FC<Props> = ({ code, language = '' }) => {
|
||||
if (!code) return null
|
||||
|
||||
return (
|
||||
<Highlight code={code} language={language} theme={themes.vsDark}>
|
||||
{({ getLineProps, getTokenProps, tokens }) => (
|
||||
<pre className="bg-black p-4 border text-xs border-border rounded overflow-x-auto">
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ className: 'table-row', line })}>
|
||||
<span className="table-cell select-none text-right text-white/25">{i + 1}</span>
|
||||
<span className="table-cell pl-4">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<CopyButton code={code} />
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
21
src/blocks/Code/Component.tsx
Normal file
21
src/blocks/Code/Component.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Code } from './Component.client'
|
||||
|
||||
export type CodeBlockProps = {
|
||||
code: string
|
||||
language?: string
|
||||
blockType: 'code'
|
||||
}
|
||||
|
||||
type Props = CodeBlockProps & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const CodeBlock: React.FC<Props> = ({ className, code, language }) => {
|
||||
return (
|
||||
<div className={[className, 'not-prose'].filter(Boolean).join(' ')}>
|
||||
<Code code={code} language={language} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/blocks/Code/CopyButton.tsx
Normal file
33
src/blocks/Code/CopyButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyIcon } from '@payloadcms/ui/icons/Copy'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function CopyButton({ code }: { code: string }) {
|
||||
const [text, setText] = useState('Copy')
|
||||
|
||||
function updateCopyStatus() {
|
||||
if (text === 'Copy') {
|
||||
setText(() => 'Copied!')
|
||||
setTimeout(() => {
|
||||
setText(() => 'Copy')
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end align-middle">
|
||||
<Button
|
||||
className="flex gap-1"
|
||||
variant={'secondary'}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(code)
|
||||
updateCopyStatus()
|
||||
}}
|
||||
>
|
||||
<p>{text}</p>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/blocks/Code/config.ts
Normal file
33
src/blocks/Code/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const Code: Block = {
|
||||
slug: 'code',
|
||||
interfaceName: 'CodeBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'language',
|
||||
type: 'select',
|
||||
defaultValue: 'typescript',
|
||||
options: [
|
||||
{
|
||||
label: 'Typescript',
|
||||
value: 'typescript',
|
||||
},
|
||||
{
|
||||
label: 'Javascript',
|
||||
value: 'javascript',
|
||||
},
|
||||
{
|
||||
label: 'CSS',
|
||||
value: 'css',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
type: 'code',
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
43
src/blocks/Content/Component.tsx
Normal file
43
src/blocks/Content/Component.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { ContentBlock as ContentBlockProps } from '@/payload-types'
|
||||
|
||||
import { CMSLink } from '../../components/Link'
|
||||
|
||||
export const ContentBlock: React.FC<ContentBlockProps> = (props) => {
|
||||
const { columns } = props
|
||||
|
||||
const colsSpanClasses = {
|
||||
full: '12',
|
||||
half: '6',
|
||||
oneThird: '4',
|
||||
twoThirds: '8',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container my-16">
|
||||
<div className="grid grid-cols-4 lg:grid-cols-12 gap-y-8 gap-x-16">
|
||||
{columns &&
|
||||
columns.length > 0 &&
|
||||
columns.map((col, index) => {
|
||||
const { enableLink, link, richText, size } = col
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`col-span-4 lg:col-span-${colsSpanClasses[size!]}`, {
|
||||
'md:col-span-2': size !== 'full',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{richText && <RichText data={richText} enableGutter={false} />}
|
||||
|
||||
{enableLink && <CMSLink {...link} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
src/blocks/Content/config.ts
Normal file
79
src/blocks/Content/config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Block, Field } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
|
||||
const columnFields: Field[] = [
|
||||
{
|
||||
name: 'size',
|
||||
type: 'select',
|
||||
defaultValue: 'oneThird',
|
||||
options: [
|
||||
{
|
||||
label: 'One Third',
|
||||
value: 'oneThird',
|
||||
},
|
||||
{
|
||||
label: 'Half',
|
||||
value: 'half',
|
||||
},
|
||||
{
|
||||
label: 'Two Thirds',
|
||||
value: 'twoThirds',
|
||||
},
|
||||
{
|
||||
label: 'Full',
|
||||
value: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
},
|
||||
{
|
||||
name: 'enableLink',
|
||||
type: 'checkbox',
|
||||
},
|
||||
link({
|
||||
overrides: {
|
||||
admin: {
|
||||
condition: (_data, siblingData) => {
|
||||
return Boolean(siblingData?.enableLink)
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
export const Content: Block = {
|
||||
slug: 'content',
|
||||
interfaceName: 'ContentBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'columns',
|
||||
type: 'array',
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: columnFields,
|
||||
},
|
||||
],
|
||||
}
|
||||
45
src/blocks/Form/Checkbox/index.tsx
Normal file
45
src/blocks/Form/Checkbox/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Checkbox: React.FC<
|
||||
CheckboxField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
const props = register(name, { required: required })
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxUi
|
||||
defaultChecked={defaultValue}
|
||||
id={name}
|
||||
{...props}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue(props.name, checked)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
163
src/blocks/Form/Component.tsx
Normal file
163
src/blocks/Form/Component.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
import type { FormFieldBlock, Form as FormType } from '@payloadcms/plugin-form-builder/types'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useForm, FormProvider } from 'react-hook-form'
|
||||
import RichText from '@/components/RichText'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { fields } from './fields'
|
||||
import { getClientSideURL } from '@/utilities/getURL'
|
||||
|
||||
export type FormBlockType = {
|
||||
blockName?: string
|
||||
blockType?: 'formBlock'
|
||||
enableIntro: boolean
|
||||
form: FormType
|
||||
introContent?: DefaultTypedEditorState
|
||||
}
|
||||
|
||||
export const FormBlock: React.FC<
|
||||
{
|
||||
id?: string
|
||||
} & FormBlockType
|
||||
> = (props) => {
|
||||
const {
|
||||
enableIntro,
|
||||
form: formFromProps,
|
||||
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
|
||||
introContent,
|
||||
} = props
|
||||
|
||||
const formMethods = useForm({
|
||||
defaultValues: formFromProps.fields,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
register,
|
||||
} = formMethods
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
|
||||
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
|
||||
const router = useRouter()
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormFieldBlock[]) => {
|
||||
let loadingTimerID: ReturnType<typeof setTimeout>
|
||||
const submitForm = async () => {
|
||||
setError(undefined)
|
||||
|
||||
const dataToSend = Object.entries(data).map(([name, value]) => ({
|
||||
field: name,
|
||||
value,
|
||||
}))
|
||||
|
||||
// delay loading indicator by 1s
|
||||
loadingTimerID = setTimeout(() => {
|
||||
setIsLoading(true)
|
||||
}, 1000)
|
||||
|
||||
try {
|
||||
const req = await fetch(`${getClientSideURL()}/api/form-submissions`, {
|
||||
body: JSON.stringify({
|
||||
form: formID,
|
||||
submissionData: dataToSend,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const res = await req.json()
|
||||
|
||||
clearTimeout(loadingTimerID)
|
||||
|
||||
if (req.status >= 400) {
|
||||
setIsLoading(false)
|
||||
|
||||
setError({
|
||||
message: res.errors?.[0]?.message || 'Internal Server Error',
|
||||
status: res.status,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
setHasSubmitted(true)
|
||||
|
||||
if (confirmationType === 'redirect' && redirect) {
|
||||
const { url } = redirect
|
||||
|
||||
const redirectUrl = url
|
||||
|
||||
if (redirectUrl) router.push(redirectUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
setIsLoading(false)
|
||||
setError({
|
||||
message: 'Something went wrong.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void submitForm()
|
||||
},
|
||||
[router, formID, redirect, confirmationType],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container lg:max-w-[48rem]">
|
||||
{enableIntro && introContent && !hasSubmitted && (
|
||||
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
|
||||
)}
|
||||
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
|
||||
<FormProvider {...formMethods}>
|
||||
{!isLoading && hasSubmitted && confirmationType === 'message' && (
|
||||
<RichText data={confirmationMessage} />
|
||||
)}
|
||||
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
|
||||
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
|
||||
{!hasSubmitted && (
|
||||
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 last:mb-0">
|
||||
{formFromProps &&
|
||||
formFromProps.fields &&
|
||||
formFromProps.fields?.map((field, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Field: React.FC<any> = fields?.[field.blockType as keyof typeof fields]
|
||||
if (Field) {
|
||||
return (
|
||||
<div className="mb-6 last:mb-0" key={index}>
|
||||
<Field
|
||||
form={formFromProps}
|
||||
{...field}
|
||||
{...formMethods}
|
||||
control={control}
|
||||
errors={errors}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button form={formID} type="submit" variant="default">
|
||||
{submitButtonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/blocks/Form/Country/index.tsx
Normal file
65
src/blocks/Form/Country/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { countryOptions } from './options'
|
||||
|
||||
export const Country: React.FC<
|
||||
CountryField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label className="" htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = countryOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countryOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
982
src/blocks/Form/Country/options.ts
Normal file
982
src/blocks/Form/Country/options.ts
Normal file
@@ -0,0 +1,982 @@
|
||||
export const countryOptions = [
|
||||
{
|
||||
label: 'Afghanistan',
|
||||
value: 'AF',
|
||||
},
|
||||
{
|
||||
label: 'Åland Islands',
|
||||
value: 'AX',
|
||||
},
|
||||
{
|
||||
label: 'Albania',
|
||||
value: 'AL',
|
||||
},
|
||||
{
|
||||
label: 'Algeria',
|
||||
value: 'DZ',
|
||||
},
|
||||
{
|
||||
label: 'American Samoa',
|
||||
value: 'AS',
|
||||
},
|
||||
{
|
||||
label: 'Andorra',
|
||||
value: 'AD',
|
||||
},
|
||||
{
|
||||
label: 'Angola',
|
||||
value: 'AO',
|
||||
},
|
||||
{
|
||||
label: 'Anguilla',
|
||||
value: 'AI',
|
||||
},
|
||||
{
|
||||
label: 'Antarctica',
|
||||
value: 'AQ',
|
||||
},
|
||||
{
|
||||
label: 'Antigua and Barbuda',
|
||||
value: 'AG',
|
||||
},
|
||||
{
|
||||
label: 'Argentina',
|
||||
value: 'AR',
|
||||
},
|
||||
{
|
||||
label: 'Armenia',
|
||||
value: 'AM',
|
||||
},
|
||||
{
|
||||
label: 'Aruba',
|
||||
value: 'AW',
|
||||
},
|
||||
{
|
||||
label: 'Australia',
|
||||
value: 'AU',
|
||||
},
|
||||
{
|
||||
label: 'Austria',
|
||||
value: 'AT',
|
||||
},
|
||||
{
|
||||
label: 'Azerbaijan',
|
||||
value: 'AZ',
|
||||
},
|
||||
{
|
||||
label: 'Bahamas',
|
||||
value: 'BS',
|
||||
},
|
||||
{
|
||||
label: 'Bahrain',
|
||||
value: 'BH',
|
||||
},
|
||||
{
|
||||
label: 'Bangladesh',
|
||||
value: 'BD',
|
||||
},
|
||||
{
|
||||
label: 'Barbados',
|
||||
value: 'BB',
|
||||
},
|
||||
{
|
||||
label: 'Belarus',
|
||||
value: 'BY',
|
||||
},
|
||||
{
|
||||
label: 'Belgium',
|
||||
value: 'BE',
|
||||
},
|
||||
{
|
||||
label: 'Belize',
|
||||
value: 'BZ',
|
||||
},
|
||||
{
|
||||
label: 'Benin',
|
||||
value: 'BJ',
|
||||
},
|
||||
{
|
||||
label: 'Bermuda',
|
||||
value: 'BM',
|
||||
},
|
||||
{
|
||||
label: 'Bhutan',
|
||||
value: 'BT',
|
||||
},
|
||||
{
|
||||
label: 'Bolivia',
|
||||
value: 'BO',
|
||||
},
|
||||
{
|
||||
label: 'Bosnia and Herzegovina',
|
||||
value: 'BA',
|
||||
},
|
||||
{
|
||||
label: 'Botswana',
|
||||
value: 'BW',
|
||||
},
|
||||
{
|
||||
label: 'Bouvet Island',
|
||||
value: 'BV',
|
||||
},
|
||||
{
|
||||
label: 'Brazil',
|
||||
value: 'BR',
|
||||
},
|
||||
{
|
||||
label: 'British Indian Ocean Territory',
|
||||
value: 'IO',
|
||||
},
|
||||
{
|
||||
label: 'Brunei Darussalam',
|
||||
value: 'BN',
|
||||
},
|
||||
{
|
||||
label: 'Bulgaria',
|
||||
value: 'BG',
|
||||
},
|
||||
{
|
||||
label: 'Burkina Faso',
|
||||
value: 'BF',
|
||||
},
|
||||
{
|
||||
label: 'Burundi',
|
||||
value: 'BI',
|
||||
},
|
||||
{
|
||||
label: 'Cambodia',
|
||||
value: 'KH',
|
||||
},
|
||||
{
|
||||
label: 'Cameroon',
|
||||
value: 'CM',
|
||||
},
|
||||
{
|
||||
label: 'Canada',
|
||||
value: 'CA',
|
||||
},
|
||||
{
|
||||
label: 'Cape Verde',
|
||||
value: 'CV',
|
||||
},
|
||||
{
|
||||
label: 'Cayman Islands',
|
||||
value: 'KY',
|
||||
},
|
||||
{
|
||||
label: 'Central African Republic',
|
||||
value: 'CF',
|
||||
},
|
||||
{
|
||||
label: 'Chad',
|
||||
value: 'TD',
|
||||
},
|
||||
{
|
||||
label: 'Chile',
|
||||
value: 'CL',
|
||||
},
|
||||
{
|
||||
label: 'China',
|
||||
value: 'CN',
|
||||
},
|
||||
{
|
||||
label: 'Christmas Island',
|
||||
value: 'CX',
|
||||
},
|
||||
{
|
||||
label: 'Cocos (Keeling) Islands',
|
||||
value: 'CC',
|
||||
},
|
||||
{
|
||||
label: 'Colombia',
|
||||
value: 'CO',
|
||||
},
|
||||
{
|
||||
label: 'Comoros',
|
||||
value: 'KM',
|
||||
},
|
||||
{
|
||||
label: 'Congo',
|
||||
value: 'CG',
|
||||
},
|
||||
{
|
||||
label: 'Congo, The Democratic Republic of the',
|
||||
value: 'CD',
|
||||
},
|
||||
{
|
||||
label: 'Cook Islands',
|
||||
value: 'CK',
|
||||
},
|
||||
{
|
||||
label: 'Costa Rica',
|
||||
value: 'CR',
|
||||
},
|
||||
{
|
||||
label: "Cote D'Ivoire",
|
||||
value: 'CI',
|
||||
},
|
||||
{
|
||||
label: 'Croatia',
|
||||
value: 'HR',
|
||||
},
|
||||
{
|
||||
label: 'Cuba',
|
||||
value: 'CU',
|
||||
},
|
||||
{
|
||||
label: 'Cyprus',
|
||||
value: 'CY',
|
||||
},
|
||||
{
|
||||
label: 'Czech Republic',
|
||||
value: 'CZ',
|
||||
},
|
||||
{
|
||||
label: 'Denmark',
|
||||
value: 'DK',
|
||||
},
|
||||
{
|
||||
label: 'Djibouti',
|
||||
value: 'DJ',
|
||||
},
|
||||
{
|
||||
label: 'Dominica',
|
||||
value: 'DM',
|
||||
},
|
||||
{
|
||||
label: 'Dominican Republic',
|
||||
value: 'DO',
|
||||
},
|
||||
{
|
||||
label: 'Ecuador',
|
||||
value: 'EC',
|
||||
},
|
||||
{
|
||||
label: 'Egypt',
|
||||
value: 'EG',
|
||||
},
|
||||
{
|
||||
label: 'El Salvador',
|
||||
value: 'SV',
|
||||
},
|
||||
{
|
||||
label: 'Equatorial Guinea',
|
||||
value: 'GQ',
|
||||
},
|
||||
{
|
||||
label: 'Eritrea',
|
||||
value: 'ER',
|
||||
},
|
||||
{
|
||||
label: 'Estonia',
|
||||
value: 'EE',
|
||||
},
|
||||
{
|
||||
label: 'Ethiopia',
|
||||
value: 'ET',
|
||||
},
|
||||
{
|
||||
label: 'Falkland Islands (Malvinas)',
|
||||
value: 'FK',
|
||||
},
|
||||
{
|
||||
label: 'Faroe Islands',
|
||||
value: 'FO',
|
||||
},
|
||||
{
|
||||
label: 'Fiji',
|
||||
value: 'FJ',
|
||||
},
|
||||
{
|
||||
label: 'Finland',
|
||||
value: 'FI',
|
||||
},
|
||||
{
|
||||
label: 'France',
|
||||
value: 'FR',
|
||||
},
|
||||
{
|
||||
label: 'French Guiana',
|
||||
value: 'GF',
|
||||
},
|
||||
{
|
||||
label: 'French Polynesia',
|
||||
value: 'PF',
|
||||
},
|
||||
{
|
||||
label: 'French Southern Territories',
|
||||
value: 'TF',
|
||||
},
|
||||
{
|
||||
label: 'Gabon',
|
||||
value: 'GA',
|
||||
},
|
||||
{
|
||||
label: 'Gambia',
|
||||
value: 'GM',
|
||||
},
|
||||
{
|
||||
label: 'Georgia',
|
||||
value: 'GE',
|
||||
},
|
||||
{
|
||||
label: 'Germany',
|
||||
value: 'DE',
|
||||
},
|
||||
{
|
||||
label: 'Ghana',
|
||||
value: 'GH',
|
||||
},
|
||||
{
|
||||
label: 'Gibraltar',
|
||||
value: 'GI',
|
||||
},
|
||||
{
|
||||
label: 'Greece',
|
||||
value: 'GR',
|
||||
},
|
||||
{
|
||||
label: 'Greenland',
|
||||
value: 'GL',
|
||||
},
|
||||
{
|
||||
label: 'Grenada',
|
||||
value: 'GD',
|
||||
},
|
||||
{
|
||||
label: 'Guadeloupe',
|
||||
value: 'GP',
|
||||
},
|
||||
{
|
||||
label: 'Guam',
|
||||
value: 'GU',
|
||||
},
|
||||
{
|
||||
label: 'Guatemala',
|
||||
value: 'GT',
|
||||
},
|
||||
{
|
||||
label: 'Guernsey',
|
||||
value: 'GG',
|
||||
},
|
||||
{
|
||||
label: 'Guinea',
|
||||
value: 'GN',
|
||||
},
|
||||
{
|
||||
label: 'Guinea-Bissau',
|
||||
value: 'GW',
|
||||
},
|
||||
{
|
||||
label: 'Guyana',
|
||||
value: 'GY',
|
||||
},
|
||||
{
|
||||
label: 'Haiti',
|
||||
value: 'HT',
|
||||
},
|
||||
{
|
||||
label: 'Heard Island and Mcdonald Islands',
|
||||
value: 'HM',
|
||||
},
|
||||
{
|
||||
label: 'Holy See (Vatican City State)',
|
||||
value: 'VA',
|
||||
},
|
||||
{
|
||||
label: 'Honduras',
|
||||
value: 'HN',
|
||||
},
|
||||
{
|
||||
label: 'Hong Kong',
|
||||
value: 'HK',
|
||||
},
|
||||
{
|
||||
label: 'Hungary',
|
||||
value: 'HU',
|
||||
},
|
||||
{
|
||||
label: 'Iceland',
|
||||
value: 'IS',
|
||||
},
|
||||
{
|
||||
label: 'India',
|
||||
value: 'IN',
|
||||
},
|
||||
{
|
||||
label: 'Indonesia',
|
||||
value: 'ID',
|
||||
},
|
||||
{
|
||||
label: 'Iran, Islamic Republic Of',
|
||||
value: 'IR',
|
||||
},
|
||||
{
|
||||
label: 'Iraq',
|
||||
value: 'IQ',
|
||||
},
|
||||
{
|
||||
label: 'Ireland',
|
||||
value: 'IE',
|
||||
},
|
||||
{
|
||||
label: 'Isle of Man',
|
||||
value: 'IM',
|
||||
},
|
||||
{
|
||||
label: 'Israel',
|
||||
value: 'IL',
|
||||
},
|
||||
{
|
||||
label: 'Italy',
|
||||
value: 'IT',
|
||||
},
|
||||
{
|
||||
label: 'Jamaica',
|
||||
value: 'JM',
|
||||
},
|
||||
{
|
||||
label: 'Japan',
|
||||
value: 'JP',
|
||||
},
|
||||
{
|
||||
label: 'Jersey',
|
||||
value: 'JE',
|
||||
},
|
||||
{
|
||||
label: 'Jordan',
|
||||
value: 'JO',
|
||||
},
|
||||
{
|
||||
label: 'Kazakhstan',
|
||||
value: 'KZ',
|
||||
},
|
||||
{
|
||||
label: 'Kenya',
|
||||
value: 'KE',
|
||||
},
|
||||
{
|
||||
label: 'Kiribati',
|
||||
value: 'KI',
|
||||
},
|
||||
{
|
||||
label: "Democratic People's Republic of Korea",
|
||||
value: 'KP',
|
||||
},
|
||||
{
|
||||
label: 'Korea, Republic of',
|
||||
value: 'KR',
|
||||
},
|
||||
{
|
||||
label: 'Kosovo',
|
||||
value: 'XK',
|
||||
},
|
||||
{
|
||||
label: 'Kuwait',
|
||||
value: 'KW',
|
||||
},
|
||||
{
|
||||
label: 'Kyrgyzstan',
|
||||
value: 'KG',
|
||||
},
|
||||
{
|
||||
label: "Lao People's Democratic Republic",
|
||||
value: 'LA',
|
||||
},
|
||||
{
|
||||
label: 'Latvia',
|
||||
value: 'LV',
|
||||
},
|
||||
{
|
||||
label: 'Lebanon',
|
||||
value: 'LB',
|
||||
},
|
||||
{
|
||||
label: 'Lesotho',
|
||||
value: 'LS',
|
||||
},
|
||||
{
|
||||
label: 'Liberia',
|
||||
value: 'LR',
|
||||
},
|
||||
{
|
||||
label: 'Libyan Arab Jamahiriya',
|
||||
value: 'LY',
|
||||
},
|
||||
{
|
||||
label: 'Liechtenstein',
|
||||
value: 'LI',
|
||||
},
|
||||
{
|
||||
label: 'Lithuania',
|
||||
value: 'LT',
|
||||
},
|
||||
{
|
||||
label: 'Luxembourg',
|
||||
value: 'LU',
|
||||
},
|
||||
{
|
||||
label: 'Macao',
|
||||
value: 'MO',
|
||||
},
|
||||
{
|
||||
label: 'Macedonia, The Former Yugoslav Republic of',
|
||||
value: 'MK',
|
||||
},
|
||||
{
|
||||
label: 'Madagascar',
|
||||
value: 'MG',
|
||||
},
|
||||
{
|
||||
label: 'Malawi',
|
||||
value: 'MW',
|
||||
},
|
||||
{
|
||||
label: 'Malaysia',
|
||||
value: 'MY',
|
||||
},
|
||||
{
|
||||
label: 'Maldives',
|
||||
value: 'MV',
|
||||
},
|
||||
{
|
||||
label: 'Mali',
|
||||
value: 'ML',
|
||||
},
|
||||
{
|
||||
label: 'Malta',
|
||||
value: 'MT',
|
||||
},
|
||||
{
|
||||
label: 'Marshall Islands',
|
||||
value: 'MH',
|
||||
},
|
||||
{
|
||||
label: 'Martinique',
|
||||
value: 'MQ',
|
||||
},
|
||||
{
|
||||
label: 'Mauritania',
|
||||
value: 'MR',
|
||||
},
|
||||
{
|
||||
label: 'Mauritius',
|
||||
value: 'MU',
|
||||
},
|
||||
{
|
||||
label: 'Mayotte',
|
||||
value: 'YT',
|
||||
},
|
||||
{
|
||||
label: 'Mexico',
|
||||
value: 'MX',
|
||||
},
|
||||
{
|
||||
label: 'Micronesia, Federated States of',
|
||||
value: 'FM',
|
||||
},
|
||||
{
|
||||
label: 'Moldova, Republic of',
|
||||
value: 'MD',
|
||||
},
|
||||
{
|
||||
label: 'Monaco',
|
||||
value: 'MC',
|
||||
},
|
||||
{
|
||||
label: 'Mongolia',
|
||||
value: 'MN',
|
||||
},
|
||||
{
|
||||
label: 'Montenegro',
|
||||
value: 'ME',
|
||||
},
|
||||
{
|
||||
label: 'Montserrat',
|
||||
value: 'MS',
|
||||
},
|
||||
{
|
||||
label: 'Morocco',
|
||||
value: 'MA',
|
||||
},
|
||||
{
|
||||
label: 'Mozambique',
|
||||
value: 'MZ',
|
||||
},
|
||||
{
|
||||
label: 'Myanmar',
|
||||
value: 'MM',
|
||||
},
|
||||
{
|
||||
label: 'Namibia',
|
||||
value: 'NA',
|
||||
},
|
||||
{
|
||||
label: 'Nauru',
|
||||
value: 'NR',
|
||||
},
|
||||
{
|
||||
label: 'Nepal',
|
||||
value: 'NP',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands',
|
||||
value: 'NL',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands Antilles',
|
||||
value: 'AN',
|
||||
},
|
||||
{
|
||||
label: 'New Caledonia',
|
||||
value: 'NC',
|
||||
},
|
||||
{
|
||||
label: 'New Zealand',
|
||||
value: 'NZ',
|
||||
},
|
||||
{
|
||||
label: 'Nicaragua',
|
||||
value: 'NI',
|
||||
},
|
||||
{
|
||||
label: 'Niger',
|
||||
value: 'NE',
|
||||
},
|
||||
{
|
||||
label: 'Nigeria',
|
||||
value: 'NG',
|
||||
},
|
||||
{
|
||||
label: 'Niue',
|
||||
value: 'NU',
|
||||
},
|
||||
{
|
||||
label: 'Norfolk Island',
|
||||
value: 'NF',
|
||||
},
|
||||
{
|
||||
label: 'Northern Mariana Islands',
|
||||
value: 'MP',
|
||||
},
|
||||
{
|
||||
label: 'Norway',
|
||||
value: 'NO',
|
||||
},
|
||||
{
|
||||
label: 'Oman',
|
||||
value: 'OM',
|
||||
},
|
||||
{
|
||||
label: 'Pakistan',
|
||||
value: 'PK',
|
||||
},
|
||||
{
|
||||
label: 'Palau',
|
||||
value: 'PW',
|
||||
},
|
||||
{
|
||||
label: 'Palestinian Territory, Occupied',
|
||||
value: 'PS',
|
||||
},
|
||||
{
|
||||
label: 'Panama',
|
||||
value: 'PA',
|
||||
},
|
||||
{
|
||||
label: 'Papua New Guinea',
|
||||
value: 'PG',
|
||||
},
|
||||
{
|
||||
label: 'Paraguay',
|
||||
value: 'PY',
|
||||
},
|
||||
{
|
||||
label: 'Peru',
|
||||
value: 'PE',
|
||||
},
|
||||
{
|
||||
label: 'Philippines',
|
||||
value: 'PH',
|
||||
},
|
||||
{
|
||||
label: 'Pitcairn',
|
||||
value: 'PN',
|
||||
},
|
||||
{
|
||||
label: 'Poland',
|
||||
value: 'PL',
|
||||
},
|
||||
{
|
||||
label: 'Portugal',
|
||||
value: 'PT',
|
||||
},
|
||||
{
|
||||
label: 'Puerto Rico',
|
||||
value: 'PR',
|
||||
},
|
||||
{
|
||||
label: 'Qatar',
|
||||
value: 'QA',
|
||||
},
|
||||
{
|
||||
label: 'Reunion',
|
||||
value: 'RE',
|
||||
},
|
||||
{
|
||||
label: 'Romania',
|
||||
value: 'RO',
|
||||
},
|
||||
{
|
||||
label: 'Russian Federation',
|
||||
value: 'RU',
|
||||
},
|
||||
{
|
||||
label: 'Rwanda',
|
||||
value: 'RW',
|
||||
},
|
||||
{
|
||||
label: 'Saint Helena',
|
||||
value: 'SH',
|
||||
},
|
||||
{
|
||||
label: 'Saint Kitts and Nevis',
|
||||
value: 'KN',
|
||||
},
|
||||
{
|
||||
label: 'Saint Lucia',
|
||||
value: 'LC',
|
||||
},
|
||||
{
|
||||
label: 'Saint Pierre and Miquelon',
|
||||
value: 'PM',
|
||||
},
|
||||
{
|
||||
label: 'Saint Vincent and the Grenadines',
|
||||
value: 'VC',
|
||||
},
|
||||
{
|
||||
label: 'Samoa',
|
||||
value: 'WS',
|
||||
},
|
||||
{
|
||||
label: 'San Marino',
|
||||
value: 'SM',
|
||||
},
|
||||
{
|
||||
label: 'Sao Tome and Principe',
|
||||
value: 'ST',
|
||||
},
|
||||
{
|
||||
label: 'Saudi Arabia',
|
||||
value: 'SA',
|
||||
},
|
||||
{
|
||||
label: 'Senegal',
|
||||
value: 'SN',
|
||||
},
|
||||
{
|
||||
label: 'Serbia',
|
||||
value: 'RS',
|
||||
},
|
||||
{
|
||||
label: 'Seychelles',
|
||||
value: 'SC',
|
||||
},
|
||||
{
|
||||
label: 'Sierra Leone',
|
||||
value: 'SL',
|
||||
},
|
||||
{
|
||||
label: 'Singapore',
|
||||
value: 'SG',
|
||||
},
|
||||
{
|
||||
label: 'Slovakia',
|
||||
value: 'SK',
|
||||
},
|
||||
{
|
||||
label: 'Slovenia',
|
||||
value: 'SI',
|
||||
},
|
||||
{
|
||||
label: 'Solomon Islands',
|
||||
value: 'SB',
|
||||
},
|
||||
{
|
||||
label: 'Somalia',
|
||||
value: 'SO',
|
||||
},
|
||||
{
|
||||
label: 'South Africa',
|
||||
value: 'ZA',
|
||||
},
|
||||
{
|
||||
label: 'South Georgia and the South Sandwich Islands',
|
||||
value: 'GS',
|
||||
},
|
||||
{
|
||||
label: 'Spain',
|
||||
value: 'ES',
|
||||
},
|
||||
{
|
||||
label: 'Sri Lanka',
|
||||
value: 'LK',
|
||||
},
|
||||
{
|
||||
label: 'Sudan',
|
||||
value: 'SD',
|
||||
},
|
||||
{
|
||||
label: 'Suriname',
|
||||
value: 'SR',
|
||||
},
|
||||
{
|
||||
label: 'Svalbard and Jan Mayen',
|
||||
value: 'SJ',
|
||||
},
|
||||
{
|
||||
label: 'Swaziland',
|
||||
value: 'SZ',
|
||||
},
|
||||
{
|
||||
label: 'Sweden',
|
||||
value: 'SE',
|
||||
},
|
||||
{
|
||||
label: 'Switzerland',
|
||||
value: 'CH',
|
||||
},
|
||||
{
|
||||
label: 'Syrian Arab Republic',
|
||||
value: 'SY',
|
||||
},
|
||||
{
|
||||
label: 'Taiwan',
|
||||
value: 'TW',
|
||||
},
|
||||
{
|
||||
label: 'Tajikistan',
|
||||
value: 'TJ',
|
||||
},
|
||||
{
|
||||
label: 'Tanzania, United Republic of',
|
||||
value: 'TZ',
|
||||
},
|
||||
{
|
||||
label: 'Thailand',
|
||||
value: 'TH',
|
||||
},
|
||||
{
|
||||
label: 'Timor-Leste',
|
||||
value: 'TL',
|
||||
},
|
||||
{
|
||||
label: 'Togo',
|
||||
value: 'TG',
|
||||
},
|
||||
{
|
||||
label: 'Tokelau',
|
||||
value: 'TK',
|
||||
},
|
||||
{
|
||||
label: 'Tonga',
|
||||
value: 'TO',
|
||||
},
|
||||
{
|
||||
label: 'Trinidad and Tobago',
|
||||
value: 'TT',
|
||||
},
|
||||
{
|
||||
label: 'Tunisia',
|
||||
value: 'TN',
|
||||
},
|
||||
{
|
||||
label: 'Turkey',
|
||||
value: 'TR',
|
||||
},
|
||||
{
|
||||
label: 'Turkmenistan',
|
||||
value: 'TM',
|
||||
},
|
||||
{
|
||||
label: 'Turks and Caicos Islands',
|
||||
value: 'TC',
|
||||
},
|
||||
{
|
||||
label: 'Tuvalu',
|
||||
value: 'TV',
|
||||
},
|
||||
{
|
||||
label: 'Uganda',
|
||||
value: 'UG',
|
||||
},
|
||||
{
|
||||
label: 'Ukraine',
|
||||
value: 'UA',
|
||||
},
|
||||
{
|
||||
label: 'United Arab Emirates',
|
||||
value: 'AE',
|
||||
},
|
||||
{
|
||||
label: 'United Kingdom',
|
||||
value: 'GB',
|
||||
},
|
||||
{
|
||||
label: 'United States',
|
||||
value: 'US',
|
||||
},
|
||||
{
|
||||
label: 'United States Minor Outlying Islands',
|
||||
value: 'UM',
|
||||
},
|
||||
{
|
||||
label: 'Uruguay',
|
||||
value: 'UY',
|
||||
},
|
||||
{
|
||||
label: 'Uzbekistan',
|
||||
value: 'UZ',
|
||||
},
|
||||
{
|
||||
label: 'Vanuatu',
|
||||
value: 'VU',
|
||||
},
|
||||
{
|
||||
label: 'Venezuela',
|
||||
value: 'VE',
|
||||
},
|
||||
{
|
||||
label: 'Viet Nam',
|
||||
value: 'VN',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, British',
|
||||
value: 'VG',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, U.S.',
|
||||
value: 'VI',
|
||||
},
|
||||
{
|
||||
label: 'Wallis and Futuna',
|
||||
value: 'WF',
|
||||
},
|
||||
{
|
||||
label: 'Western Sahara',
|
||||
value: 'EH',
|
||||
},
|
||||
{
|
||||
label: 'Yemen',
|
||||
value: 'YE',
|
||||
},
|
||||
{
|
||||
label: 'Zambia',
|
||||
value: 'ZM',
|
||||
},
|
||||
{
|
||||
label: 'Zimbabwe',
|
||||
value: 'ZW',
|
||||
},
|
||||
]
|
||||
38
src/blocks/Form/Email/index.tsx
Normal file
38
src/blocks/Form/Email/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Email: React.FC<
|
||||
EmailField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="text"
|
||||
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
15
src/blocks/Form/Error/index.tsx
Normal file
15
src/blocks/Form/Error/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
export const Error = ({ name }: { name: string }) => {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
return (
|
||||
<div className="mt-2 text-red-500 text-sm">
|
||||
{(errors[name]?.message as string) || 'This field is required'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
src/blocks/Form/Message/index.tsx
Normal file
13
src/blocks/Form/Message/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import RichText from '@/components/RichText'
|
||||
import React from 'react'
|
||||
|
||||
import { Width } from '../Width'
|
||||
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
|
||||
return (
|
||||
<Width className="my-12" width="100">
|
||||
{message && <RichText data={message} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
36
src/blocks/Form/Number/index.tsx
Normal file
36
src/blocks/Form/Number/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
export const Number: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="number"
|
||||
{...register(name, { required })}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
63
src/blocks/Form/Select/index.tsx
Normal file
63
src/blocks/Form/Select/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select as SelectComponent,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Select: React.FC<
|
||||
SelectField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = options.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectComponent>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
64
src/blocks/Form/State/index.tsx
Normal file
64
src/blocks/Form/State/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { StateField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { stateOptions } from './options'
|
||||
|
||||
export const State: React.FC<
|
||||
StateField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = stateOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stateOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
52
src/blocks/Form/State/options.ts
Normal file
52
src/blocks/Form/State/options.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export const stateOptions = [
|
||||
{ label: 'Alabama', value: 'AL' },
|
||||
{ label: 'Alaska', value: 'AK' },
|
||||
{ label: 'Arizona', value: 'AZ' },
|
||||
{ label: 'Arkansas', value: 'AR' },
|
||||
{ label: 'California', value: 'CA' },
|
||||
{ label: 'Colorado', value: 'CO' },
|
||||
{ label: 'Connecticut', value: 'CT' },
|
||||
{ label: 'Delaware', value: 'DE' },
|
||||
{ label: 'Florida', value: 'FL' },
|
||||
{ label: 'Georgia', value: 'GA' },
|
||||
{ label: 'Hawaii', value: 'HI' },
|
||||
{ label: 'Idaho', value: 'ID' },
|
||||
{ label: 'Illinois', value: 'IL' },
|
||||
{ label: 'Indiana', value: 'IN' },
|
||||
{ label: 'Iowa', value: 'IA' },
|
||||
{ label: 'Kansas', value: 'KS' },
|
||||
{ label: 'Kentucky', value: 'KY' },
|
||||
{ label: 'Louisiana', value: 'LA' },
|
||||
{ label: 'Maine', value: 'ME' },
|
||||
{ label: 'Maryland', value: 'MD' },
|
||||
{ label: 'Massachusetts', value: 'MA' },
|
||||
{ label: 'Michigan', value: 'MI' },
|
||||
{ label: 'Minnesota', value: 'MN' },
|
||||
{ label: 'Mississippi', value: 'MS' },
|
||||
{ label: 'Missouri', value: 'MO' },
|
||||
{ label: 'Montana', value: 'MT' },
|
||||
{ label: 'Nebraska', value: 'NE' },
|
||||
{ label: 'Nevada', value: 'NV' },
|
||||
{ label: 'New Hampshire', value: 'NH' },
|
||||
{ label: 'New Jersey', value: 'NJ' },
|
||||
{ label: 'New Mexico', value: 'NM' },
|
||||
{ label: 'New York', value: 'NY' },
|
||||
{ label: 'North Carolina', value: 'NC' },
|
||||
{ label: 'North Dakota', value: 'ND' },
|
||||
{ label: 'Ohio', value: 'OH' },
|
||||
{ label: 'Oklahoma', value: 'OK' },
|
||||
{ label: 'Oregon', value: 'OR' },
|
||||
{ label: 'Pennsylvania', value: 'PA' },
|
||||
{ label: 'Rhode Island', value: 'RI' },
|
||||
{ label: 'South Carolina', value: 'SC' },
|
||||
{ label: 'South Dakota', value: 'SD' },
|
||||
{ label: 'Tennessee', value: 'TN' },
|
||||
{ label: 'Texas', value: 'TX' },
|
||||
{ label: 'Utah', value: 'UT' },
|
||||
{ label: 'Vermont', value: 'VT' },
|
||||
{ label: 'Virginia', value: 'VA' },
|
||||
{ label: 'Washington', value: 'WA' },
|
||||
{ label: 'West Virginia', value: 'WV' },
|
||||
{ label: 'Wisconsin', value: 'WI' },
|
||||
{ label: 'Wyoming', value: 'WY' },
|
||||
]
|
||||
32
src/blocks/Form/Text/index.tsx
Normal file
32
src/blocks/Form/Text/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Text: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input defaultValue={defaultValue} id={name} type="text" {...register(name, { required })} />
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
40
src/blocks/Form/Textarea/index.tsx
Normal file
40
src/blocks/Form/Textarea/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea as TextAreaComponent } from '@/components/ui/textarea'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Textarea: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
rows?: number
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, rows = 3, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<TextAreaComponent
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
rows={rows}
|
||||
{...register(name, { required: required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
13
src/blocks/Form/Width/index.tsx
Normal file
13
src/blocks/Form/Width/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export const Width: React.FC<{
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
width?: number | string
|
||||
}> = ({ children, className, width }) => {
|
||||
return (
|
||||
<div className={className} style={{ maxWidth: width ? `${width}%` : undefined }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/blocks/Form/config.ts
Normal file
51
src/blocks/Form/config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const FormBlock: Block = {
|
||||
slug: 'formBlock',
|
||||
interfaceName: 'FormBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'form',
|
||||
type: 'relationship',
|
||||
relationTo: 'forms',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'enableIntro',
|
||||
type: 'checkbox',
|
||||
label: 'Enable Intro Content',
|
||||
},
|
||||
{
|
||||
name: 'introContent',
|
||||
type: 'richText',
|
||||
admin: {
|
||||
condition: (_, { enableIntro }) => Boolean(enableIntro),
|
||||
},
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: 'Intro Content',
|
||||
},
|
||||
],
|
||||
graphQL: {
|
||||
singularName: 'FormBlock',
|
||||
},
|
||||
labels: {
|
||||
plural: 'Form Blocks',
|
||||
singular: 'Form Block',
|
||||
},
|
||||
}
|
||||
21
src/blocks/Form/fields.tsx
Normal file
21
src/blocks/Form/fields.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Checkbox } from './Checkbox'
|
||||
import { Country } from './Country'
|
||||
import { Email } from './Email'
|
||||
import { Message } from './Message'
|
||||
import { Number } from './Number'
|
||||
import { Select } from './Select'
|
||||
import { State } from './State'
|
||||
import { Text } from './Text'
|
||||
import { Textarea } from './Textarea'
|
||||
|
||||
export const fields = {
|
||||
checkbox: Checkbox,
|
||||
country: Country,
|
||||
email: Email,
|
||||
message: Message,
|
||||
number: Number,
|
||||
select: Select,
|
||||
state: State,
|
||||
text: Text,
|
||||
textarea: Textarea,
|
||||
}
|
||||
67
src/blocks/MediaBlock/Component.tsx
Normal file
67
src/blocks/MediaBlock/Component.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { StaticImageData } from 'next/image'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { MediaBlock as MediaBlockProps } from '@/payload-types'
|
||||
|
||||
import { Media } from '../../components/Media'
|
||||
|
||||
type Props = MediaBlockProps & {
|
||||
breakout?: boolean
|
||||
captionClassName?: string
|
||||
className?: string
|
||||
enableGutter?: boolean
|
||||
imgClassName?: string
|
||||
staticImage?: StaticImageData
|
||||
disableInnerContainer?: boolean
|
||||
}
|
||||
|
||||
export const MediaBlock: React.FC<Props> = (props) => {
|
||||
const {
|
||||
captionClassName,
|
||||
className,
|
||||
enableGutter = true,
|
||||
imgClassName,
|
||||
media,
|
||||
staticImage,
|
||||
disableInnerContainer,
|
||||
} = props
|
||||
|
||||
let caption
|
||||
if (media && typeof media === 'object') caption = media.caption
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'',
|
||||
{
|
||||
container: enableGutter,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(media || staticImage) && (
|
||||
<Media
|
||||
imgClassName={cn('border border-border rounded-[0.8rem]', imgClassName)}
|
||||
resource={media}
|
||||
src={staticImage}
|
||||
/>
|
||||
)}
|
||||
{caption && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-6',
|
||||
{
|
||||
container: !disableInnerContainer,
|
||||
},
|
||||
captionClassName,
|
||||
)}
|
||||
>
|
||||
<RichText data={caption} enableGutter={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/blocks/MediaBlock/config.ts
Normal file
14
src/blocks/MediaBlock/config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const MediaBlock: Block = {
|
||||
slug: 'mediaBlock',
|
||||
interfaceName: 'MediaBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'media',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
32
src/blocks/RelatedPosts/Component.tsx
Normal file
32
src/blocks/RelatedPosts/Component.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { Post } from '@/payload-types'
|
||||
|
||||
import { Card } from '../../components/Card'
|
||||
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export type RelatedPostsProps = {
|
||||
className?: string
|
||||
docs?: Post[]
|
||||
introContent?: DefaultTypedEditorState
|
||||
}
|
||||
|
||||
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {
|
||||
const { className, docs, introContent } = props
|
||||
|
||||
return (
|
||||
<div className={clsx('lg:container', className)}>
|
||||
{introContent && <RichText data={introContent} enableGutter={false} />}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 items-stretch">
|
||||
{docs?.map((doc, index) => {
|
||||
if (typeof doc === 'string') return null
|
||||
|
||||
return <Card key={index} doc={doc} relationTo="posts" showCategories />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/blocks/RenderBlocks.tsx
Normal file
51
src/blocks/RenderBlocks.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import type { Page } from '@/payload-types'
|
||||
|
||||
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
|
||||
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
|
||||
import { ContentBlock } from '@/blocks/Content/Component'
|
||||
import { FormBlock } from '@/blocks/Form/Component'
|
||||
import { MediaBlock } from '@/blocks/MediaBlock/Component'
|
||||
|
||||
const blockComponents = {
|
||||
archive: ArchiveBlock,
|
||||
content: ContentBlock,
|
||||
cta: CallToActionBlock,
|
||||
formBlock: FormBlock,
|
||||
mediaBlock: MediaBlock,
|
||||
}
|
||||
|
||||
export const RenderBlocks: React.FC<{
|
||||
blocks: Page['layout'][0][]
|
||||
}> = (props) => {
|
||||
const { blocks } = props
|
||||
|
||||
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
|
||||
|
||||
if (hasBlocks) {
|
||||
return (
|
||||
<Fragment>
|
||||
{blocks.map((block, index) => {
|
||||
const { blockType } = block
|
||||
|
||||
if (blockType && blockType in blockComponents) {
|
||||
const Block = blockComponents[blockType]
|
||||
|
||||
if (Block) {
|
||||
return (
|
||||
<div className="my-16" key={index}>
|
||||
{/* @ts-expect-error there may be some mismatch between the expected types here */}
|
||||
<Block {...block} disableInnerContainer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
28
src/collections/Categories.ts
Normal file
28
src/collections/Categories.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { anyone } from '../access/anyone'
|
||||
import { authenticated } from '../access/authenticated'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: 'categories',
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: anyone,
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
slugField({
|
||||
position: undefined,
|
||||
}),
|
||||
],
|
||||
}
|
||||
81
src/collections/Media.ts
Normal file
81
src/collections/Media.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { anyone } from '../access/anyone'
|
||||
import { authenticated } from '../access/authenticated'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
folders: true,
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: anyone,
|
||||
update: authenticated,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
//required: true,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
upload: {
|
||||
// Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload
|
||||
staticDir: path.resolve(dirname, '../../public/media'),
|
||||
adminThumbnail: 'thumbnail',
|
||||
focalPoint: true,
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'thumbnail',
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
name: 'square',
|
||||
width: 500,
|
||||
height: 500,
|
||||
},
|
||||
{
|
||||
name: 'small',
|
||||
width: 600,
|
||||
},
|
||||
{
|
||||
name: 'medium',
|
||||
width: 900,
|
||||
},
|
||||
{
|
||||
name: 'large',
|
||||
width: 1400,
|
||||
},
|
||||
{
|
||||
name: 'xlarge',
|
||||
width: 1920,
|
||||
},
|
||||
{
|
||||
name: 'og',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
crop: 'center',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
43
src/collections/Pages/hooks/revalidatePage.ts
Normal file
43
src/collections/Pages/hooks/revalidatePage.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
import type { Page } from '../../../payload-types'
|
||||
|
||||
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating page at path: ${path}`)
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('pages-sitemap', 'max')
|
||||
}
|
||||
|
||||
// If the page was previously published, we need to revalidate the old path
|
||||
if (previousDoc?._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
|
||||
|
||||
revalidatePath(oldPath)
|
||||
revalidateTag('pages-sitemap', 'max')
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
|
||||
revalidatePath(path)
|
||||
revalidateTag('pages-sitemap', 'max')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
136
src/collections/Pages/index.ts
Normal file
136
src/collections/Pages/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { Archive } from '../../blocks/ArchiveBlock/config'
|
||||
import { CallToAction } from '../../blocks/CallToAction/config'
|
||||
import { Content } from '../../blocks/Content/config'
|
||||
import { FormBlock } from '../../blocks/Form/config'
|
||||
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
||||
import { hero } from '@/heros/config'
|
||||
import { slugField } from 'payload'
|
||||
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
|
||||
import { generatePreviewPath } from '../../utilities/generatePreviewPath'
|
||||
import { revalidateDelete, revalidatePage } from './hooks/revalidatePage'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaImageField,
|
||||
MetaTitleField,
|
||||
OverviewField,
|
||||
PreviewField,
|
||||
} from '@payloadcms/plugin-seo/fields'
|
||||
|
||||
export const Pages: CollectionConfig<'pages'> = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: authenticatedOrPublished,
|
||||
update: authenticated,
|
||||
},
|
||||
// This config controls what's populated by default when a page is referenced
|
||||
// https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property
|
||||
// Type safe if the collection slug generic is passed to `CollectionConfig` - `CollectionConfig<'pages'>
|
||||
defaultPopulate: {
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
livePreview: {
|
||||
url: ({ data, req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug,
|
||||
collection: 'pages',
|
||||
req,
|
||||
}),
|
||||
},
|
||||
preview: (data, { req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug as string,
|
||||
collection: 'pages',
|
||||
req,
|
||||
}),
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
fields: [hero],
|
||||
label: 'Hero',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'blocks',
|
||||
blocks: [CallToAction, Content, MediaBlock, Archive, FormBlock],
|
||||
required: true,
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
label: 'Content',
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
OverviewField({
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
imagePath: 'meta.image',
|
||||
}),
|
||||
MetaTitleField({
|
||||
hasGenerateFn: true,
|
||||
}),
|
||||
MetaImageField({
|
||||
relationTo: 'media',
|
||||
}),
|
||||
|
||||
MetaDescriptionField({}),
|
||||
PreviewField({
|
||||
// if the `generateUrl` function is configured
|
||||
hasGenerateFn: true,
|
||||
|
||||
// field paths to match the target field for data
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePage],
|
||||
beforeChange: [populatePublishedAt],
|
||||
afterDelete: [revalidateDelete],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 100, // We set this interval for optimal live preview
|
||||
},
|
||||
schedulePublish: true,
|
||||
},
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
}
|
||||
37
src/collections/Posts/hooks/populateAuthors.ts
Normal file
37
src/collections/Posts/hooks/populateAuthors.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { CollectionAfterReadHook } from 'payload'
|
||||
import { User } from 'src/payload-types'
|
||||
|
||||
// The `user` collection has access control locked so that users are not publicly accessible
|
||||
// This means that we need to populate the authors manually here to protect user privacy
|
||||
// GraphQL will not return mutated user data that differs from the underlying schema
|
||||
// So we use an alternative `populatedAuthors` field to populate the user data, hidden from the admin UI
|
||||
export const populateAuthors: CollectionAfterReadHook = async ({ doc, req, req: { payload } }) => {
|
||||
if (doc?.authors && doc?.authors?.length > 0) {
|
||||
const authorDocs: User[] = []
|
||||
|
||||
for (const author of doc.authors) {
|
||||
try {
|
||||
const authorDoc = await payload.findByID({
|
||||
id: typeof author === 'object' ? author?.id : author,
|
||||
collection: 'users',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (authorDoc) {
|
||||
authorDocs.push(authorDoc)
|
||||
}
|
||||
|
||||
if (authorDocs.length > 0) {
|
||||
doc.populatedAuthors = authorDocs.map((authorDoc) => ({
|
||||
id: authorDoc.id,
|
||||
name: authorDoc.name,
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// swallow error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
44
src/collections/Posts/hooks/revalidatePost.ts
Normal file
44
src/collections/Posts/hooks/revalidatePost.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
import type { Post } from '../../../payload-types'
|
||||
|
||||
export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = `/posts/${doc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating post at path: ${path}`)
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('posts-sitemap', 'max')
|
||||
}
|
||||
|
||||
// If the post was previously published, we need to revalidate the old path
|
||||
if (previousDoc._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = `/posts/${previousDoc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating old post at path: ${oldPath}`)
|
||||
|
||||
revalidatePath(oldPath)
|
||||
revalidateTag('posts-sitemap', 'max')
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = `/posts/${doc?.slug}`
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('posts-sitemap', 'max')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
233
src/collections/Posts/index.ts
Normal file
233
src/collections/Posts/index.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
BlocksFeature,
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
HorizontalRuleFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { Banner } from '../../blocks/Banner/config'
|
||||
import { Code } from '../../blocks/Code/config'
|
||||
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
||||
import { generatePreviewPath } from '../../utilities/generatePreviewPath'
|
||||
import { populateAuthors } from './hooks/populateAuthors'
|
||||
import { revalidateDelete, revalidatePost } from './hooks/revalidatePost'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaImageField,
|
||||
MetaTitleField,
|
||||
OverviewField,
|
||||
PreviewField,
|
||||
} from '@payloadcms/plugin-seo/fields'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig<'posts'> = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: authenticatedOrPublished,
|
||||
update: authenticated,
|
||||
},
|
||||
// This config controls what's populated by default when a post is referenced
|
||||
// https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property
|
||||
// Type safe if the collection slug generic is passed to `CollectionConfig` - `CollectionConfig<'posts'>
|
||||
defaultPopulate: {
|
||||
title: true,
|
||||
slug: true,
|
||||
categories: true,
|
||||
meta: {
|
||||
image: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
livePreview: {
|
||||
url: ({ data, req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug,
|
||||
collection: 'posts',
|
||||
req,
|
||||
}),
|
||||
},
|
||||
preview: (data, { req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug as string,
|
||||
collection: 'posts',
|
||||
req,
|
||||
}),
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'heroImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
BlocksFeature({ blocks: [Banner, Code, MediaBlock] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
HorizontalRuleFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
label: 'Content',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'relatedPosts',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
filterOptions: ({ id }) => {
|
||||
return {
|
||||
id: {
|
||||
not_in: [id],
|
||||
},
|
||||
}
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'posts',
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'categories',
|
||||
},
|
||||
],
|
||||
label: 'Meta',
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
OverviewField({
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
imagePath: 'meta.image',
|
||||
}),
|
||||
MetaTitleField({
|
||||
hasGenerateFn: true,
|
||||
}),
|
||||
MetaImageField({
|
||||
relationTo: 'media',
|
||||
}),
|
||||
|
||||
MetaDescriptionField({}),
|
||||
PreviewField({
|
||||
// if the `generateUrl` function is configured
|
||||
hasGenerateFn: true,
|
||||
|
||||
// field paths to match the target field for data
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (siblingData._status === 'published' && !value) {
|
||||
return new Date()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'authors',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'users',
|
||||
},
|
||||
// This field is only used to populate the user data via the `populateAuthors` hook
|
||||
// This is because the `user` collection has access control locked to protect user privacy
|
||||
// GraphQL will also not return mutated user data that differs from the underlying schema
|
||||
{
|
||||
name: 'populatedAuthors',
|
||||
type: 'array',
|
||||
access: {
|
||||
update: () => false,
|
||||
},
|
||||
admin: {
|
||||
disabled: true,
|
||||
readOnly: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePost],
|
||||
afterRead: [populateAuthors],
|
||||
afterDelete: [revalidateDelete],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 100, // We set this interval for optimal live preview
|
||||
},
|
||||
schedulePublish: true,
|
||||
},
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
}
|
||||
26
src/collections/Users/index.ts
Normal file
26
src/collections/Users/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
access: {
|
||||
admin: authenticated,
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: authenticated,
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['name', 'email'],
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
7
src/components/AdminBar/index.scss
Normal file
7
src/components/AdminBar/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@import '@payloadcms/ui/scss';
|
||||
|
||||
.admin-bar {
|
||||
@include small-break {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
89
src/components/AdminBar/index.tsx
Normal file
89
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>
|
||||
)
|
||||
}
|
||||
12
src/components/BeforeDashboard/SeedButton/index.scss
Normal file
12
src/components/BeforeDashboard/SeedButton/index.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.seedButton {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
88
src/components/BeforeDashboard/SeedButton/index.tsx
Normal file
88
src/components/BeforeDashboard/SeedButton/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/components/BeforeDashboard/index.scss
Normal file
24
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;
|
||||
}
|
||||
}
|
||||
69
src/components/BeforeDashboard/index.tsx
Normal file
69
src/components/BeforeDashboard/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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>
|
||||
{'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
src/components/BeforeLogin/index.tsx
Normal file
14
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
|
||||
80
src/components/Card/index.tsx
Normal file
80
src/components/Card/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'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">
|
||||
{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>
|
||||
)}
|
||||
{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
src/components/CollectionArchive/index.tsx
Normal file
32
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
src/components/Link/index.tsx
Normal file
66
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
src/components/LivePreviewListener/index.tsx
Normal file
10
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
src/components/Logo/Logo.tsx
Normal file
29
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/3.x/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
105
src/components/Media/ImageMedia/index.tsx
Normal file
105
src/components/Media/ImageMedia/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'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,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABchJREFUWEdtlwtTG0kMhHtGM7N+AAdcDsjj///EBLzenbtuadbLJaZUTlHB+tRqSesETB3IABqQG1KbUFqDlQorBSmboqeEBcC1d8zrCixXYGZcgMsFmH8B+AngHdurAmXKOE8nHOoBrU6opcGswPi5KSP9CcBaQ9kACJH/ALAA1xm4zMD8AczvQCcAQeJVAZsy7nYApTSUzwCHUKACeUJi9TsFci7AHmDtuHYqQIC9AgQYKnSwNAig4NyOOwXq/xU47gDYggarjIpsRSEA3Fqw7AGkwgW4fgALAdiC2btKgNZwbgdMbEFpqFR2UyCR8xwAhf8bUHIGk1ckMyB5C1YkeWAdAPQBAeiD6wVYPoD1HUgXwFagZAGc6oSpTmilopoD5GzISQD3odcNIFca0BUQQM5YA2DpHV0AYURBDIAL0C+ugC0C4GedSsVUmwC8/4w8TPiwU6AClJ5RWL1PgQNkrABWdKB3YF3cBwRY5lsI4ApkKpCQi+FIgFJU/TDgDuAxAAwonJuKpGD1rkCXCR1ALyrAUSSEQAhwBdYZ6DPAgSUA2c1wKIZmRcHxMzMYR9DH8NlbkAwwApSAcABwBwTAbb6owAr0AFiZPILVEyCtMmK2jCkTwFDNUNj7nJETQx744gCUmgkZVGJUHyakEZE4W91jtGFA9KsD8Z3JFYDlhGYZLWcllwJMnplcPy+csFAgAAaIDOgeuAGoB96GLZg4kmtfMjnr6ig5oSoySsoy3ya/FMivXZWxwr0KIf9nACbfqcBEgmBSAtAlIT83R+70IWpyACamIjf5E1Iqb9ECVmnoI/FvAIRk8s2J0Y5IquQDgB+5wpScw5AUTC75VTmTs+72NUzoCvQIaAXv5Q8PDAZKLD+MxLv3RFE7KlsQChgBIlKiCv5ByaZv3gJZNm8AnVMhAN+EjrtTYQMICJpu6/0aiQnhClANlz+Bw0cIWa8ev0sBrtrhAyaXEnrfGfATQJiRKih5vKeOHNXXPFrgyamAADh0Q4F2/sESojomDS9o9k0b0H83xjB8qL+JNoTjN+enjpaBpingRh4e8MSugudM030A8FeqMI6PFIgNyPehkpZWGFEAARIQdH5LcAAqIACHkAJqg4OoBccHAuz76wr4BbzFOEa8iBuAZB8AtJHLP2VgMgJw/EIBowo7HxCAH3V6dAXEE/vZ5aZIA8BP8RKhm7Cp8BnAMnAQADdgQDA520AVIpScP+enHz0Gwp25h4i2dPg5FkDXrbsdJikQwXuWgaM5gEMk1AgH4DKKFjDf3bMD+FjEeIxLlRKYnBk2BbquvSDCAQ4gwZiMAAmH4gBTyRtEsYxi7gP6QSrc//39BrDNqG8rtYTmC4BV1SfMhOhaumFCT87zy4pPhQBZEK1kQVRjJBBi7AOlePgyAPYjwlvtagx9e/dnQraAyS894TIkkAIEYMKEc8k4EqJ68lZ5jjNqcQC2QteQOf7659umwBgPybNtK4dg9WvnMyFwXYGP7uEO1lwJgAnPNeMYMVXbIIYKFioI4PGFt+BWPVfmWJdjW2lTUnLGCswECAgaUy86iwA1464ajo0QhgMBFGyBoZahANsMpMfXr1JA1SN29m5lqgXj+UPV85uRA7yv/KYUO4Tk7Hc1AZwbIRzg0AyNj2UlAMwfSLSMnl7fdAbcxHuA27YaAMvaQ4GOjwX4RTUGAG8Ge14N963g1AynqUiFqRX9noasxT4b8entNRQYyamk/3tYcHsO7R3XJRRYOn4tw4iUnwBM5gDnySGOreAwAGo8F9IDHEcq8Pz2Kg/oXCpuIL6tOPD8LsDn0ABYQoGFRowlsAEUPPDrGAGowAbgKsgDMmE8mDy/vXQ9IAwI7u4wta+gAdAdgB64Ah9SgD4IgGKhwACoAjgNgFDhtxY8f33ZTMjqdTAiHMBPrn8ZWkEfzFdX4Oc1AHg3+ADbvN8PU8WdFKg4Tt6CQy2+D4YHaMT/JP4XzbAq98cPDIUAAAAASUVORK5CYII='
|
||||
|
||||
/**
|
||||
* ImageMedia
|
||||
*
|
||||
* This component passes a **relative** `src` (e.g. `/media/...`) to Next.js Image.
|
||||
* The `getMediaUrl` utility constructs the full URL by prepending the base URL from env vars
|
||||
* (NEXT_PUBLIC_SERVER_URL). Next.js then optimizes this using `remotePatterns` configured
|
||||
* in next.config.js — no custom `loader` needed.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Resource URL from Payload: `/media/image-123.jpg`
|
||||
* 2. getMediaUrl() adds base URL: `https://yourdomain.com/media/image-123.jpg`
|
||||
* 3. Next.js Image optimizes via remotePatterns: `/_next/image?url=...&w=1200&q=75`
|
||||
*
|
||||
* If your storage/plugin returns **external CDN URLs** (e.g. `https://cdn.example.com/...`),
|
||||
* choose ONE of the following:
|
||||
* A) Allow the remote host in next.config.js:
|
||||
* images: { remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }] }
|
||||
* B) Provide a **custom loader** for CDN-specific transforms:
|
||||
* const imageLoader: ImageLoader = ({ src, width, quality }) =>
|
||||
* `https://cdn.example.com${src}?w=${width}&q=${quality ?? 75}`
|
||||
* <Image loader={imageLoader} src="/media/hero.jpg" width={1200} height={600} alt="" />
|
||||
* C) Skip optimization:
|
||||
* <Image unoptimized src="https://cdn.example.com/hero.jpg" width={1200} height={600} alt="" />
|
||||
*
|
||||
* TL;DR: Template uses relative URLs + getMediaUrl() to construct full URLs, then relies on
|
||||
* remotePatterns for optimization. Only add `loader` if using external CDNs with custom transforms.
|
||||
*/
|
||||
|
||||
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
src/components/Media/VideoMedia/index.tsx
Normal file
46
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
src/components/Media/index.tsx
Normal file
25
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
src/components/Media/types.ts
Normal file
22
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
src/components/PageRange/index.tsx
Normal file
57
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
src/components/Pagination/index.tsx
Normal file
101
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
src/components/PayloadRedirects/index.tsx
Normal file
48
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
src/components/RichText/index.tsx
Normal file
81
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[#EFBF04] text-[#101828] shadow-xs hover:bg-[#03093A] hover:text-white",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
accent:
|
||||
"bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
|
||||
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3 max-md:min-h-11",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 max-md:min-h-11",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4 max-md:min-h-11",
|
||||
icon: "size-9 max-md:min-h-11 max-md:min-w-11",
|
||||
clear: '',
|
||||
default: 'h-10 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-9 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-11 rounded-md px-8 has-[>svg]:px-4',
|
||||
icon: 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({ asChild = false, className, size, variant, ...props }) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -1,92 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const Card: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
className={cn('bg-card text-card-foreground rounded-lg border shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
className={cn('flex flex-col gap-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
className={cn('text-2xl leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return <div data-slot="card-content" className={cn('p-6 pt-0', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
29
src/components/ui/checkbox.tsx
Normal file
29
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'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<React.ComponentProps<typeof CheckboxPrimitive.Root>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-4 shrink-0 rounded-[4px] border shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,23 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as React from 'react'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full border border-stone-200 bg-white px-4 py-2 text-base md:text-sm text-[#101828] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#EFBF04] focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-all",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = ({
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<input
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
className,
|
||||
)}
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cn } from "@/lib/utils"
|
||||
'use client'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-[10px] font-bold uppercase tracking-[0.2em] text-[#101828]/50 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
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
src/components/ui/pagination.tsx
Normal file
92
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,
|
||||
}
|
||||
166
src/components/ui/select.tsx
Normal file
166
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'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: React.FC<React.ComponentProps<typeof SelectPrimitive.Root>> = (props) => {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
const SelectGroup: React.FC<React.ComponentProps<typeof SelectPrimitive.Group>> = (props) => {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
const SelectValue: React.FC<React.ComponentProps<typeof SelectPrimitive.Value>> = (props) => {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
const SelectTrigger: React.FC<React.ComponentProps<typeof SelectPrimitive.Trigger>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground aria-invalid:border-destructive ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-0 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectContent: React.FC<React.ComponentProps<typeof SelectPrimitive.Content>> = ({
|
||||
children,
|
||||
className,
|
||||
position = 'popper',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
|
||||
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}
|
||||
{...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)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectLabel: React.FC<React.ComponentProps<typeof SelectPrimitive.Label>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectItem: React.FC<React.ComponentProps<typeof SelectPrimitive.Item>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectSeparator: React.FC<React.ComponentProps<typeof SelectPrimitive.Separator>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectScrollUpButton: React.FC<
|
||||
React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectScrollDownButton: React.FC<
|
||||
React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as React from 'react'
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[100px] w-full border border-stone-200 bg-white px-4 py-3 text-base md:text-sm text-[#101828] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#EFBF04] focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-none",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/50 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
||||
12
src/cssVariables.js
Normal file
12
src/cssVariables.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// Keep these in sync with the CSS variables in your tailwind configuration
|
||||
|
||||
export const cssVariables = {
|
||||
breakpoints: {
|
||||
'3xl': 1920,
|
||||
'2xl': 1536,
|
||||
xl: 1280,
|
||||
lg: 1024,
|
||||
md: 768,
|
||||
sm: 640,
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user