Merge branch 'master' into feat/local-snippets

This commit is contained in:
Ivan Vasilov committed 2025-08-12 11:38:54 +02:00
commit f1b226e58d
320 files changed
+29690 -12893

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# [LOCAL ENV] - NEVER USE PROD VALUES LOCALLY
# as editing PayloadCMS typescript config INSTANTLY CHANGES DB SCHEMA in dev mode
# The values set here are the default values provided when running `supabase start`.
# Do not add secrets here, this file is git-tracked. Use .env.local instead.
DATABASE_URI=postgresql://postgres:postgres@127.0.0.1:34322/postgres
PAYLOAD_SECRET=secret
S3_BUCKET=cms
S3_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c
S3_SECRET_ACCESS_KEY=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907
S3_REGION=local
S3_ENDPOINT=http://127.0.0.1:34321/storage/v1/s3
BLOG_APP_URL=http://localhost:3000
NEXT_PUBLIC_SERVER_URL=http://localhost:3000/blog
CRON_SECRET=secret
PREVIEW_SECRET=secret
-13
View File
@@ -1,13 +0,0 @@
DATABASE_URI=postgres://postgres:<password>@127.0.0.1:5432/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=
S3_ENDPOINT=
NEXT_PUBLIC_SERVER_URL=http://localhost:3000/blog
CRON_SECRET=YOUR_CRON_SECRET_HERE
PREVIEW_SECRET=YOUR_SECRET_HERE
BLOG_APP_URL=http://localhost:3000
File renamed without changes.
+2 -3
View File
@@ -29,7 +29,8 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
!.env
.env.local
# vercel
.vercel
@@ -38,6 +39,4 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
.env
/media
+48
View File
@@ -0,0 +1,48 @@
# Dependencies
node_modules
**/node_modules
# Build artifacts
.next/cache
.turbo
.eslintcache
# Development files
*.log
.env.local
.env.*.local
# Test files
**/*.test.*
**/*.spec.*
__tests__
**/__tests__
coverage
# Documentation
README.md
*.md
docs
# IDE files
.vscode
.idea
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Other apps (since we're only deploying CMS)
../studio
../www
../docs
../design-system
../ui-library
../../examples
../../tests
../../supabase
../../packages/ui-patterns
../../packages/ui
../../packages/shared-data
+1 -1
View File
@@ -15,7 +15,7 @@ services:
env_file:
- .env
# Uncomment the following to use postgres
# Uncomment the following to use postgres
postgres:
restart: always
image: postgres:latest
+18 -1
View File
@@ -1,6 +1,23 @@
import { withPayload } from '@payloadcms/next/withPayload'
import redirects from './redirects.js'
const redirects = async () => {
const internetExplorerRedirect = {
destination: '/ie-incompatible.html',
has: [
{
type: 'header',
key: 'user-agent',
value: '(.*Trident.*)', // all ie browsers
},
],
permanent: false,
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
}
const redirects = [internetExplorerRedirect]
return redirects
}
const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
+32 -16
View File
@@ -5,43 +5,59 @@
"license": "MIT",
"scripts": {
"build": "cross-env NODE_OPTIONS=--no-deprecation next build --turbopack",
"ci": "pnpm migrate && pnpm build",
"clean": "rimraf node_modules .next",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --turbopack --port 3030",
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"migrate": "cross-env NODE_OPTIONS=--no-deprecation tsx scripts/migrate.ts",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"ci": "cross-env NODE_OPTIONS=--no-deprecation payload migrate && pnpm build",
"clean": "rimraf node_modules .next",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start --port 3030",
"vercel-build": "pnpm migrate && pnpm build",
"typecheck_IGNORED": "tsc --noEmit"
},
"dependencies": {
"@payloadcms/db-postgres": "3.33.0",
"@payloadcms/live-preview-react": "^3.33.0",
"@payloadcms/next": "3.33.0",
"@payloadcms/payload-cloud": "3.33.0",
"@payloadcms/plugin-form-builder": "3.33.0",
"@payloadcms/plugin-nested-docs": "3.33.0",
"@payloadcms/plugin-seo": "3.33.0",
"@payloadcms/richtext-lexical": "3.33.0",
"@payloadcms/storage-s3": "3.33.0",
"@payloadcms/ui": "3.33.0",
"@payloadcms/admin-bar": "^3.50.0",
"@payloadcms/db-postgres": "^3.50.0",
"@payloadcms/live-preview-react": "^3.50.0",
"@payloadcms/next": "3.50.0",
"@payloadcms/payload-cloud": "3.50.0",
"@payloadcms/plugin-form-builder": "3.50.0",
"@payloadcms/plugin-nested-docs": "3.50.0",
"@payloadcms/plugin-seo": "3.50.0",
"@payloadcms/richtext-lexical": "3.50.0",
"@payloadcms/storage-s3": "3.50.0",
"@payloadcms/ui": "3.50.0",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^1.2.1",
"common": "workspace:*",
"config": "workspace:*",
"cross-env": "^7.0.3",
"eslint-config-supabase": "workspace:*",
"graphql": "^16.8.1",
"graphql": "^16.11.0",
"image-size": "2.0.2",
"lucide-react": "^0.511.0",
"next": "catalog:",
"payload": "3.33.0",
"payload": "3.50.0",
"pg": "^8.16.3",
"prism-react-renderer": "^2.3.1",
"react": "catalog:",
"react-dom": "catalog:",
"sharp": "0.32.6"
"sharp": "0.32.6",
"tailwind-merge": "^1.13.2"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tsx": "^4.19.3",
"typescript": "5.7.3"
}
}
-20
View File
@@ -1,20 +0,0 @@
const redirects = async () => {
const internetExplorerRedirect = {
destination: '/ie-incompatible.html',
has: [
{
type: 'header',
key: 'user-agent',
value: '(.*Trident.*)', // all ie browsers
},
],
permanent: false,
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
}
const redirects = [internetExplorerRedirect]
return redirects
}
export default redirects
+28
View File
@@ -0,0 +1,28 @@
import payload from 'payload'
// Use a minimal config for migrations to avoid importing the full app graph during build
import payloadConfig from '../src/payload.migrate.config.ts'
async function run() {
try {
await payload.init({ config: payloadConfig })
// Ensure non-interactive: remove any dev-mode migration sentinel rows
try {
await payload.delete({
collection: 'payload-migrations',
where: { batch: { equals: -1 } },
})
} catch {}
await payload.db.migrate()
// eslint-disable-next-line no-console
console.log('✅ Payload migrations complete')
process.exit(0)
} catch (err) {
// eslint-disable-next-line no-console
console.error('❌ Payload migrations failed:', err)
process.exit(1)
}
}
run()
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AccessArgs, FieldAccess } from 'payload'
import type { User } from '@/payload-types'
import type { User } from '../payload-types'
type isAdmin = (args: AccessArgs<User>) => boolean
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AccessArgs } from 'payload'
import type { User } from '@/payload-types'
import type { User } from '../payload-types'
type isAuthenticated = (args: AccessArgs<User>) => boolean
@@ -1,65 +0,0 @@
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>
)
}
@@ -1,94 +0,0 @@
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',
},
}
@@ -1,23 +0,0 @@
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>
)
}
@@ -1,42 +0,0 @@
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',
},
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function CopyButton({ code }: { code: string }) {
<div className="flex justify-end align-middle">
<Button
className="flex gap-1"
variant={'secondary'}
variant="secondary"
onClick={async () => {
await navigator.clipboard.writeText(code)
updateCopyStatus()
-43
View File
@@ -1,43 +0,0 @@
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
View File
@@ -1,79 +0,0 @@
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,
},
],
}
@@ -1,45 +0,0 @@
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>
)
}
-162
View File
@@ -1,162 +0,0 @@
'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 { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { fields } from './fields'
import { getClientSideURL } from '@/utilities/getURL'
export type FormBlockType = {
blockName?: string
blockType?: 'formBlock'
enableIntro: boolean
form: FormType
introContent?: SerializedEditorState
}
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) => {
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>
)
}
@@ -1,65 +0,0 @@
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
View File
@@ -1,982 +0,0 @@
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
View File
@@ -1,38 +0,0 @@
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
View File
@@ -1,15 +0,0 @@
'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>
)
}
@@ -1,13 +0,0 @@
import RichText from '@/components/RichText'
import React from 'react'
import { Width } from '../Width'
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
export const Message: React.FC<{ message: SerializedEditorState }> = ({ message }) => {
return (
<Width className="my-12" width="100">
{message && <RichText data={message} />}
</Width>
)
}
-36
View File
@@ -1,36 +0,0 @@
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
View File
@@ -1,63 +0,0 @@
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
View File
@@ -1,64 +0,0 @@
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
View File
@@ -1,52 +0,0 @@
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
View File
@@ -1,32 +0,0 @@
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>
)
}
@@ -1,40 +0,0 @@
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
View File
@@ -1,13 +0,0 @@
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
View File
@@ -1,51 +0,0 @@
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
View File
@@ -1,21 +0,0 @@
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,
}
@@ -1,32 +0,0 @@
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 { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
export type RelatedPostsProps = {
className?: string
docs?: Post[]
introContent?: SerializedEditorState
}
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
View File
@@ -1,51 +0,0 @@
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
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { CollectionConfig } from 'payload'
import type { CollectionConfig } from 'payload'
export const Authors: CollectionConfig = {
slug: 'authors',
+1 -1
View File
@@ -1,4 +1,4 @@
import { CollectionConfig } from 'payload'
import type { CollectionConfig } from 'payload'
export const Categories: CollectionConfig = {
slug: 'categories',
@@ -1,10 +1,10 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
// Avoid importing next/cache at module scope so migrations can run in a plain Node env
import type { Customer } from '../../../payload-types'
export const revalidateCustomer: CollectionAfterChangeHook<Customer> = ({
export const revalidateCustomer: CollectionAfterChangeHook<Customer> = async ({
doc,
previousDoc,
req: { payload, context },
@@ -14,9 +14,13 @@ export const revalidateCustomer: CollectionAfterChangeHook<Customer> = ({
const path = `/customers/${doc.slug}`
payload.logger.info(`Revalidating event at path: ${path}`)
revalidatePath(path)
revalidateTag('customers-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(path)
revalidateTag('customers-sitemap')
} catch {
// no-op when not running inside Next runtime (e.g., during payload migrate)
}
}
// If the event was previously published, we need to revalidate the old path
@@ -25,22 +29,27 @@ export const revalidateCustomer: CollectionAfterChangeHook<Customer> = ({
payload.logger.info(`Revalidating old event at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('customers-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(oldPath)
revalidateTag('customers-sitemap')
} catch {}
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Customer> = ({
export const revalidateDelete: CollectionAfterDeleteHook<Customer> = async ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) {
const path = `/customers/${doc?.slug}`
revalidatePath(path)
revalidateTag('customers-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(path)
revalidateTag('customers-sitemap')
} catch {}
}
return doc
+9 -9
View File
@@ -9,15 +9,15 @@ import {
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
import { isAnyone } from '../../access/isAnyone.ts'
import { isAuthenticated } from '../../access/isAuthenticated.ts'
import { Banner } from '../../blocks/Banner/config'
import { Code } from '../../blocks/Code/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
import { Quote } from '../../blocks/Quote/config'
import { YouTube } from '../../blocks/YouTube/config'
import { revalidateDelete, revalidateCustomer } from './hooks/revalidateCustomer'
import { Banner } from '../../blocks/Banner/config.ts'
import { Code } from '../../blocks/Code/config.ts'
import { MediaBlock } from '../../blocks/MediaBlock/config.ts'
import { Quote } from '../../blocks/Quote/config.ts'
import { YouTube } from '../../blocks/YouTube/config.ts'
import { revalidateDelete, revalidateCustomer } from './hooks/revalidateCustomer.ts'
import {
MetaDescriptionField,
@@ -26,7 +26,7 @@ import {
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
import { slugField } from '../../fields/slug/index.ts'
const industryOptions = [
{ label: 'Healthcare', value: 'healthcare' },
@@ -1,10 +1,10 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
// Avoid importing next/cache at module scope so migrations can run in a plain Node env
import type { Event } from '../../../payload-types'
export const revalidateEvent: CollectionAfterChangeHook<Event> = ({
export const revalidateEvent: CollectionAfterChangeHook<Event> = async ({
doc,
previousDoc,
req: { payload, context },
@@ -14,9 +14,11 @@ export const revalidateEvent: CollectionAfterChangeHook<Event> = ({
const path = `/events/${doc.slug}`
payload.logger.info(`Revalidating event at path: ${path}`)
revalidatePath(path)
revalidateTag('events-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(path)
revalidateTag('events-sitemap')
} catch {}
}
// If the event was previously published, we need to revalidate the old path
@@ -25,19 +27,27 @@ export const revalidateEvent: CollectionAfterChangeHook<Event> = ({
payload.logger.info(`Revalidating old event at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('events-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(oldPath)
revalidateTag('events-sitemap')
} catch {}
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Event> = ({ doc, req: { context } }) => {
export const revalidateDelete: CollectionAfterDeleteHook<Event> = async ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) {
const path = `/events/${doc?.slug}`
revalidatePath(path)
revalidateTag('events-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(path)
revalidateTag('events-sitemap')
} catch {}
}
return doc
+10 -10
View File
@@ -9,15 +9,15 @@ import {
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
import { isAnyone } from '../../access/isAnyone.ts'
import { isAuthenticated } from '../../access/isAuthenticated.ts'
import { Banner } from '@/blocks/Banner/config'
import { Code } from '@/blocks/Code/config'
import { MediaBlock } from '@/blocks/MediaBlock/config'
import { Quote } from '@/blocks/Quote/config'
import { YouTube } from '@/blocks/YouTube/config'
import { revalidateDelete, revalidateEvent } from './hooks/revalidateEvent'
import { Banner } from '../../blocks/Banner/config.ts'
import { Code } from '../../blocks/Code/config.ts'
import { MediaBlock } from '../../blocks/MediaBlock/config.ts'
import { Quote } from '../../blocks/Quote/config.ts'
import { YouTube } from '../../blocks/YouTube/config.ts'
import { revalidateDelete, revalidateEvent } from './hooks/revalidateEvent.ts'
import {
MetaDescriptionField,
@@ -26,8 +26,8 @@ import {
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
import { timezoneOptions } from '../../utilities/timezones'
import { slugField } from '../../fields/slug/index.ts'
import { timezoneOptions } from '../../utilities/timezones.ts'
const eventTypeOptions = [
{ label: 'Conference', value: 'conference' },
+2 -2
View File
@@ -5,8 +5,8 @@ import {
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
import { isAnyone } from '../access/isAnyone.ts'
import { isAuthenticated } from '../access/isAuthenticated.ts'
export const Media: CollectionConfig = {
slug: 'media',
@@ -1,5 +1,5 @@
import type { CollectionAfterReadHook } from 'payload'
import { Author } from 'src/payload-types'
import type { Author } from '../../../payload-types'
export const populateAuthors: CollectionAfterReadHook = async ({ doc, req: { payload } }) => {
if (doc?.authors && doc?.authors?.length > 0) {
@@ -1,10 +1,10 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
// Avoid importing next/cache at module scope so migrations can run in a plain Node env
import type { Post } from '../../../payload-types'
export const revalidatePost: CollectionAfterChangeHook<Post> = ({
export const revalidatePost: CollectionAfterChangeHook<Post> = async ({
doc,
previousDoc,
req: { payload, context },
@@ -14,9 +14,11 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
const path = `/posts/${doc.slug}`
payload.logger.info(`Revalidating post at path: ${path}`)
revalidatePath(path)
revalidateTag('posts-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(path)
revalidateTag('posts-sitemap')
} catch {}
}
// If the post was previously published, we need to revalidate the old path
@@ -25,19 +27,27 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
payload.logger.info(`Revalidating old post at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('posts-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(oldPath)
revalidateTag('posts-sitemap')
} catch {}
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
export const revalidateDelete: CollectionAfterDeleteHook<Post> = async ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) {
const path = `/posts/${doc?.slug}`
revalidatePath(path)
revalidateTag('posts-sitemap')
try {
const { revalidatePath, revalidateTag } = await import('next/cache')
revalidatePath(path)
revalidateTag('posts-sitemap')
} catch {}
}
return doc
+10 -10
View File
@@ -9,16 +9,16 @@ import {
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
import { isAnyone } from '../../access/isAnyone.ts'
import { isAuthenticated } from '../../access/isAuthenticated.ts'
import { Banner } from '@/blocks/Banner/config'
import { Code } from '@/blocks/Code/config'
import { MediaBlock } from '@/blocks/MediaBlock/config'
import { Quote } from '@/blocks/Quote/config'
import { YouTube } from '@/blocks/YouTube/config'
import { populateAuthors } from './hooks/populateAuthors'
import { revalidateDelete, revalidatePost } from './hooks/revalidatePost'
import { Banner } from '../../blocks/Banner/config.ts'
import { Code } from '../../blocks/Code/config.ts'
import { MediaBlock } from '../../blocks/MediaBlock/config.ts'
import { Quote } from '../../blocks/Quote/config.ts'
import { YouTube } from '../../blocks/YouTube/config.ts'
import { populateAuthors } from './hooks/populateAuthors.ts'
import { revalidateDelete, revalidatePost } from './hooks/revalidatePost.ts'
import {
MetaDescriptionField,
@@ -27,7 +27,7 @@ import {
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
import { slugField } from '../../fields/slug/index.ts'
const launchweekOptions = [
{ label: '6', value: '6' },
+1 -1
View File
@@ -1,4 +1,4 @@
import { CollectionConfig } from 'payload'
import type { CollectionConfig } from 'payload'
export const Tags: CollectionConfig = {
slug: 'tags',
+2 -2
View File
@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isAdminFieldLevel } from '@/access/isAdmin'
import { isAdminOrSelf } from '@/access/isAdminOrSelf'
import { isAdmin, isAdminFieldLevel } from '../access/isAdmin.ts'
import { isAdminOrSelf } from '../access/isAdminOrSelf.ts'
export const Users: CollectionConfig = {
slug: 'users',
+9 -5
View File
@@ -35,7 +35,7 @@ export const Card: React.FC<{
'border border-border rounded-lg overflow-hidden bg-card hover:cursor-pointer',
className
)}
ref={card.ref}
ref={card.ref as React.Ref<HTMLDivElement>}
>
<div className="relative w-full ">
{!metaImage && <div className="">No image</div>}
@@ -48,15 +48,15 @@ export const Card: React.FC<{
<div>
{categories?.map((category, index) => {
if (typeof category === 'object') {
const { title: titleFromCategory } = category
const { name: nameFromCategory } = category
const categoryTitle = titleFromCategory || 'Untitled category'
const categoryName = nameFromCategory || 'Untitled category'
const isLast = index === categories.length - 1
return (
<Fragment key={index}>
{categoryTitle}
{categoryName}
{!isLast && <Fragment>, &nbsp;</Fragment>}
</Fragment>
)
@@ -71,7 +71,11 @@ export const Card: React.FC<{
{titleToUse && (
<div className="prose">
<h3>
<Link className="not-prose" href={href} ref={link.ref}>
<Link
className="not-prose"
href={href}
ref={link.ref as React.Ref<HTMLAnchorElement>}
>
{titleToUse}
</Link>
</h3>
+4 -6
View File
@@ -3,7 +3,7 @@ import { cn } from '@/utilities/ui'
import Link from 'next/link'
import React from 'react'
import type { Page, Post } from '@/payload-types'
import type { Post } from '@/payload-types'
type CMSLinkType = {
appearance?: 'inline' | ButtonProps['variant']
@@ -12,8 +12,8 @@ type CMSLinkType = {
label?: string | null
newTab?: boolean | null
reference?: {
relationTo: 'pages' | 'posts'
value: Page | Post | string | number
relationTo: 'posts'
value: Post | string | number
} | null
size?: ButtonProps['size'] | null
type?: 'custom' | 'reference' | null
@@ -35,9 +35,7 @@ export const CMSLink: React.FC<CMSLinkType> = (props) => {
const href =
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${
reference.value.slug
}`
? `/${reference.value.slug}`
: url
if (!href) return null
@@ -1,48 +0,0 @@
import type React from 'react'
import type { 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 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()
}
+1 -9
View File
@@ -15,13 +15,11 @@ import { CodeBlock, CodeBlockProps } from '@/blocks/Code/Component'
import type {
BannerBlock as BannerBlockProps,
CallToActionBlock as CTABlockProps,
MediaBlock as MediaBlockProps,
QuoteBlock as QuoteBlockProps,
YouTubeBlock as YouTubeBlockProps,
} from '@/payload-types'
import { BannerBlock } from '@/blocks/Banner/Component'
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
import { QuoteBlock } from '@/blocks/Quote/Component'
import { YouTubeBlock } from '@/blocks/YouTube/Component'
import { cn } from '@/utilities/ui'
@@ -29,12 +27,7 @@ import { cn } from '@/utilities/ui'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<
| CTABlockProps
| MediaBlockProps
| BannerBlockProps
| CodeBlockProps
| QuoteBlockProps
| YouTubeBlockProps
MediaBlockProps | BannerBlockProps | CodeBlockProps | QuoteBlockProps | YouTubeBlockProps
>
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
@@ -62,7 +55,6 @@ const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters })
/>
),
code: ({ node }) => <CodeBlock className="col-start-2" {...node.fields} />,
cta: ({ node }) => <CallToActionBlock {...node.fields} />,
quote: ({ node }) => <QuoteBlock className="col-start-2" {...node.fields} />,
youtube: ({ node }) => <YouTubeBlock className="col-start-2" {...node.fields} />,
},
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Field, GroupField } from 'payload'
import deepMerge from '@/utilities/deepMerge'
import deepMerge from '../utilities/deepMerge.ts'
export type LinkAppearances = 'default' | 'outline'
@@ -75,7 +75,7 @@ export const link: LinkType = ({ appearances, disableLabel = false, overrides =
condition: (_, siblingData) => siblingData?.type === 'reference',
},
label: 'Document to link to',
relationTo: ['pages', 'posts'],
relationTo: ['posts'],
required: true,
},
{
+3 -3
View File
@@ -1,9 +1,9 @@
import type { ArrayField, Field } from 'payload'
import type { LinkAppearances } from './link'
import type { LinkAppearances } from './link.ts'
import deepMerge from '@/utilities/deepMerge'
import { link } from './link'
import deepMerge from '../utilities/deepMerge.ts'
import { link } from './link.ts'
type LinkGroupType = (options?: {
appearances?: LinkAppearances[] | false
+1 -1
View File
@@ -1,6 +1,6 @@
import type { CheckboxField, TextField } from 'payload'
import { formatSlugHook } from './formatSlug'
import { formatSlugHook } from './formatSlug.ts'
type Overrides = {
slugOverrides?: Partial<TextField>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
INSERT INTO storage.buckets (id, name, public) VALUES ('cms', 'cms', false)
ON CONFLICT (id) DO NOTHING;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DELETE FROM storage.objects WHERE bucket_id = 'cms';
DELETE FROM storage.buckets WHERE id = 'cms';
`)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,21 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "users_sessions" CASCADE;`)
}
+16 -4
View File
@@ -1,9 +1,21 @@
import * as migration_20250529_103319 from './20250529_103319';
import * as migration_20250619_155345 from './20250619_155345.ts';
import * as migration_20250619_160549 from './20250619_160549.ts';
import * as migration_20250808_145722_update_payload_migrations from './20250808_145722_update_payload_migrations.ts';
export const migrations = [
{
up: migration_20250529_103319.up,
down: migration_20250529_103319.down,
name: '20250529_103319'
up: migration_20250619_155345.up,
down: migration_20250619_155345.down,
name: '20250619_155345',
},
{
up: migration_20250619_160549.up,
down: migration_20250619_160549.down,
name: '20250619_160549',
},
{
up: migration_20250808_145722_update_payload_migrations.up,
down: migration_20250808_145722_update_payload_migrations.down,
name: '20250808_145722_update_payload_migrations'
},
];
+14
View File
@@ -987,6 +987,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -1454,6 +1461,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
+71 -16
View File
@@ -1,23 +1,78 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import { plugins } from './plugins'
import { defaultLexical } from '@/fields/defaultLexical'
import { getServerSideURL } from './utilities/getURL'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { s3Storage } from '@payloadcms/storage-s3'
import { buildConfig, type Plugin } from 'payload'
import { defaultLexical } from './fields/defaultLexical.ts'
import { getServerSideURL } from './utilities/getURL.ts'
import { Authors } from './collections/Authors'
import { Categories } from './collections/Categories'
import { Customers } from './collections/Customers'
import { Events } from './collections/Events'
import { Media } from './collections/Media'
import { Posts } from './collections/Posts'
import { Tags } from './collections/Tags'
import { Users } from './collections/Users'
import type { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
import type { Customer, Event, Post } from './payload-types'
import { Authors } from './collections/Authors.ts'
import { Categories } from './collections/Categories.ts'
import { Customers } from './collections/Customers/index.ts'
import { Events } from './collections/Events/index.ts'
import { Media } from './collections/Media.ts'
import { Posts } from './collections/Posts/index.ts'
import { Tags } from './collections/Tags.ts'
import { Users } from './collections/Users.ts'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const siteName = 'Supabase'
const generateTitle: GenerateTitle<Post & Customer & Event> = ({ doc, collectionSlug }: any) => {
switch (collectionSlug) {
case 'customers':
return `${doc.name} | ${siteName} Customer Stories`
case 'events':
return `${doc.title} | ${siteName} Events`
case 'posts':
return doc.title
default:
return `${doc.title} | ${siteName}`
}
}
const generateURL: GenerateURL<Post> = ({ doc }: any) => {
const url = getServerSideURL()
return doc?.slug ? `${url}/${doc.slug}` : url
}
const plugins: Plugin[] = [
nestedDocsPlugin({
collections: ['categories'],
generateURL: (docs) => docs.reduce((url, doc) => `${url}/${doc.slug}`, ''),
}),
seoPlugin({
generateTitle,
generateURL,
}),
payloadCloudPlugin(),
s3Storage({
collections: {
media: {
prefix: 'media',
},
},
bucket: process.env.S3_BUCKET || '',
config: {
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
},
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT,
},
}),
]
export default buildConfig({
admin: {
@@ -59,7 +114,7 @@ export default buildConfig({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
schemaName: 'cms-payload',
// schemaName: 'cms-payload',
}),
sharp,
plugins,
+11
View File
@@ -0,0 +1,11 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { buildConfig } from 'payload'
export default buildConfig({
secret: process.env.PAYLOAD_SECRET,
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
})
-87
View File
@@ -1,87 +0,0 @@
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
// import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { s3Storage } from '@payloadcms/storage-s3'
import { Plugin } from 'payload'
import { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
// import { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { Customer, Event, Post } from '@/payload-types'
import { getServerSideURL } from '@/utilities/getURL'
const siteName = 'Supabase'
const generateTitle: GenerateTitle<Post & Customer & Event> = ({ doc, collectionSlug }) => {
switch (collectionSlug) {
case 'customers':
return `${doc.name} | ${siteName} Customer Stories`
case 'events':
return `${doc.title} | ${siteName} Events`
case 'posts':
return doc.title
default:
return `${doc.title} | ${siteName}`
}
}
const generateURL: GenerateURL<Post> = ({ doc }) => {
const url = getServerSideURL()
return doc?.slug ? `${url}/${doc.slug}` : url
}
export const plugins: Plugin[] = [
nestedDocsPlugin({
collections: ['categories'],
generateURL: (docs) => docs.reduce((url, doc) => `${url}/${doc.slug}`, ''),
}),
seoPlugin({
generateTitle,
generateURL,
}),
// formBuilderPlugin({
// fields: {
// payment: false,
// },
// formOverrides: {
// fields: ({ defaultFields }) => {
// return defaultFields.map((field) => {
// if ('name' in field && field.name === 'confirmationMessage') {
// return {
// ...field,
// editor: lexicalEditor({
// features: ({ rootFeatures }) => {
// return [
// ...rootFeatures,
// FixedToolbarFeature(),
// HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
// ]
// },
// }),
// }
// }
// return field
// })
// },
// },
// }),
payloadCloudPlugin(),
s3Storage({
collections: {
media: {
prefix: 'media',
},
},
bucket: process.env.S3_BUCKET || '',
config: {
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
},
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT,
},
}),
]
+5 -1
View File
@@ -27,7 +27,11 @@ export const HeaderThemeProvider = ({ children }: { children: React.ReactNode })
setThemeState(themeToSet)
}, [])
return <HeaderThemeContext value={{ headerTheme, setHeaderTheme }}>{children}</HeaderThemeContext>
return (
<HeaderThemeContext.Provider value={{ headerTheme, setHeaderTheme }}>
{children}
</HeaderThemeContext.Provider>
)
}
export const useHeaderTheme = (): ContextType => use(HeaderThemeContext)
+1 -1
View File
@@ -51,7 +51,7 @@ export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
setThemeState(themeToSet)
}, [])
return <ThemeContext value={{ setTheme, theme }}>{children}</ThemeContext>
return <ThemeContext.Provider value={{ setTheme, theme }}>{children}</ThemeContext.Provider>
}
export const useTheme = (): ThemeContextType => use(ThemeContext)
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Theme } from './types'
import type { Theme } from './types.ts'
export const themeLocalStorageKey = 'payload-theme'
-41
View File
@@ -1,41 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React, { useState, useEffect } from 'react'
import { useDebounce } from '@/utilities/useDebounce'
import { useRouter } from 'next/navigation'
export const Search: React.FC = () => {
const [value, setValue] = useState('')
const router = useRouter()
const debouncedValue = useDebounce(value)
useEffect(() => {
router.push(`/search${debouncedValue ? `?q=${debouncedValue}` : ''}`)
}, [debouncedValue, router])
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
}}
>
<Label htmlFor="search" className="sr-only">
Search
</Label>
<Input
id="search"
onChange={(event) => {
setValue(event.target.value)
}}
placeholder="Search"
/>
<button type="submit" className="sr-only">
submit
</button>
</form>
</div>
)
}
-44
View File
@@ -1,44 +0,0 @@
import { BeforeSync, DocToSync } from '@payloadcms/plugin-search/types'
export const beforeSyncWithSearch: BeforeSync = async ({ originalDoc, searchDoc }) => {
const {
doc: { relationTo: collection },
} = searchDoc
const { slug, id, categories, title, meta } = originalDoc
const modifiedDoc: DocToSync = {
...searchDoc,
slug,
meta: {
...meta,
title: meta?.title || title,
image: meta?.image?.id || meta?.image,
description: meta?.description,
},
categories: [],
}
if (categories && Array.isArray(categories) && categories.length > 0) {
// get full categories and keep a flattened copy of their most important properties
try {
const mappedCategories = categories.map((category) => {
const { id, title } = category
return {
relationTo: 'categories',
id,
title,
}
})
modifiedDoc.categories = mappedCategories
} catch (_err) {
console.error(
`Failed. Category not found when syncing collection '${collection}' with id: '${id}' to search.`
)
}
}
return modifiedDoc
}
-61
View File
@@ -1,61 +0,0 @@
import { Field } from 'payload'
export const searchFields: Field[] = [
{
name: 'slug',
type: 'text',
index: true,
admin: {
readOnly: true,
},
},
{
name: 'meta',
label: 'Meta',
type: 'group',
index: true,
admin: {
readOnly: true,
},
fields: [
{
type: 'text',
name: 'title',
label: 'Title',
},
{
type: 'text',
name: 'description',
label: 'Description',
},
{
name: 'image',
label: 'Image',
type: 'upload',
relationTo: 'media',
},
],
},
{
label: 'Categories',
name: 'categories',
type: 'array',
admin: {
readOnly: true,
},
fields: [
{
name: 'relationTo',
type: 'text',
},
{
name: 'id',
type: 'text',
},
{
name: 'title',
type: 'text',
},
],
},
]
-24
View File
@@ -1,24 +0,0 @@
import { Post } from '@/payload-types'
/**
* Formats an array of populatedAuthors from Posts into a prettified string.
* @param authors - The populatedAuthors array from a Post.
* @returns A prettified string of authors.
* @example
*
* [Author1, Author2] becomes 'Author1 and Author2'
* [Author1, Author2, Author3] becomes 'Author1, Author2, and Author3'
*
*/
export const formatAuthors = (
authors: NonNullable<NonNullable<Post['populatedAuthors']>[number]>[]
) => {
// Ensure we don't have any authors without a name
const authorNames = authors.map((author) => author.name).filter(Boolean)
if (authorNames.length === 0) return ''
if (authorNames.length === 1) return authorNames[0]
if (authorNames.length === 2) return `${authorNames[0]} and ${authorNames[1]}`
return `${authorNames.slice(0, -1).join(', ')} and ${authorNames[authorNames.length - 1]}`
}
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
import type { Media, Post, Config } from '../payload-types'
import { mergeOpenGraph } from './mergeOpenGraph'
import { getServerSideURL } from './getURL'
import { mergeOpenGraph } from './mergeOpenGraph.ts'
import { getServerSideURL } from './getURL.ts'
const getImageURL = (image?: Media | Config['db']['defaultIDType'] | null) => {
const serverUrl = getServerSideURL()
@@ -2,7 +2,6 @@ import { PayloadRequest, CollectionSlug } from 'payload'
const collectionPrefixMap: Partial<Record<CollectionSlug, string>> = {
posts: '/blog',
pages: '',
}
type Props = {
+14 -5
View File
@@ -2,7 +2,7 @@ import type { Config } from 'src/payload-types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { unstable_cache } from 'next/cache'
// Do not import next/cache at module scope to keep migrate step Node-compatible
type Collection = keyof Config['collections']
@@ -25,7 +25,16 @@ async function getDocument(collection: Collection, slug: string, depth = 0) {
/**
* Returns a unstable_cache function mapped with the cache tag for the slug
*/
export const getCachedDocument = (collection: Collection, slug: string) =>
unstable_cache(async () => getDocument(collection, slug), [collection, slug], {
tags: [`${collection}_${slug}`],
})
export const getCachedDocument = (collection: Collection, slug: string) => {
return async () => {
try {
const { unstable_cache } = await import('next/cache')
const cached = unstable_cache(async () => getDocument(collection, slug), [collection, slug], {
tags: [`${collection}_${slug}`],
})
return cached()
} catch {
return getDocument(collection, slug)
}
}
}
+14 -5
View File
@@ -2,7 +2,7 @@ import type { Config } from 'src/payload-types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { unstable_cache } from 'next/cache'
// Do not import next/cache at module scope to keep migrate step Node-compatible
type Global = keyof Config['globals']
@@ -20,7 +20,16 @@ async function getGlobal(slug: Global, depth = 0) {
/**
* Returns a unstable_cache function mapped with the cache tag for the slug
*/
export const getCachedGlobal = (slug: Global, depth = 0) =>
unstable_cache(async () => getGlobal(slug, depth), [slug], {
tags: [`global_${slug}`],
})
export const getCachedGlobal = (slug: Global, depth = 0) => {
return async () => {
try {
const { unstable_cache } = await import('next/cache')
const cached = unstable_cache(async () => getGlobal(slug, depth), [slug], {
tags: [`global_${slug}`],
})
return cached()
} catch {
return getGlobal(slug, depth)
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import type { User } from '../payload-types'
import { getClientSideURL } from './getURL'
import { getClientSideURL } from './getURL.ts'
export const getMeUser = async (args?: {
nullUserRedirect?: string
-26
View File
@@ -1,26 +0,0 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { unstable_cache } from 'next/cache'
export async function getRedirects(depth = 1) {
const payload = await getPayload({ config: configPromise })
const { docs: redirects } = await payload.find({
collection: 'redirects',
depth,
limit: 0,
pagination: false,
})
return redirects
}
/**
* Returns a unstable_cache function mapped with the cache tag for 'redirects'.
*
* Cache all redirects together to avoid multiple fetches.
*/
export const getCachedRedirects = () =>
unstable_cache(async () => getRedirects(), ['redirects'], {
tags: ['redirects'],
})
+2 -2
View File
@@ -1,4 +1,4 @@
import canUseDOM from './canUseDOM'
import canUseDOM from './canUseDOM.ts'
export const getServerSideURL = () => {
let url = process.env.NEXT_PUBLIC_SERVER_URL
@@ -8,7 +8,7 @@ export const getServerSideURL = () => {
}
if (!url) {
url = 'http://localhost:3000'
url = 'http://localhost:3030'
}
return url
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'
import { getServerSideURL } from './getURL'
import { getServerSideURL } from './getURL.ts'
const defaultOpenGraph: Metadata['openGraph'] = {
type: 'website',
+39 -13
View File
@@ -2,12 +2,12 @@
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "cms-payload"
project_id = "cms"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
port = 34321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
@@ -23,17 +23,17 @@ enabled = false
[db]
# Port to use for the local database URL.
port = 54322
port = 34322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
shadow_port = 34320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
port = 34329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
@@ -46,6 +46,8 @@ max_client_conn = 100
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
@@ -57,6 +59,16 @@ enabled = true
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
@@ -67,7 +79,7 @@ enabled = true
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
port = 34323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
@@ -78,10 +90,10 @@ openai_api_key = "env(OPENAI_API_KEY)"
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
port = 34324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# smtp_port = 34325
# pop3_port = 34326
# admin_email = "admin@email.com"
# sender_name = "Admin"
@@ -110,6 +122,8 @@ site_url = "http://127.0.0.1:3000"
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
@@ -140,6 +154,8 @@ token_refresh = 150
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
@@ -200,6 +216,11 @@ max_frequency = "5s"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
@@ -252,6 +273,11 @@ url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
@@ -276,7 +302,7 @@ enabled = false
# domain = "example.clerk.accounts.dev"
[edge_runtime]
enabled = true
enabled = false
# Configure one of the supported request policies: `oneshot`, `per_worker`.
# Use `oneshot` for hot reload, or `per_worker` for load testing.
policy = "oneshot"
@@ -289,8 +315,8 @@ deno_version = 1
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
enabled = false
port = 34327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
+1 -2
View File
@@ -1,10 +1,8 @@
{
"compilerOptions": {
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"esModuleInterop": true,
"target": "ES2022",
@@ -18,6 +16,7 @@
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"sourceMap": true,
"isolatedModules": true,
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "pnpm vercel-build",
"installCommand": "pnpm install"
}
+1 -1
View File
@@ -44,7 +44,7 @@
"ui": "workspace:*",
"ui-patterns": "workspace:*",
"unist-util-visit": "^5.0.0",
"zod": "^3.22.4"
"zod": "^3.25.76"
},
"devDependencies": {
"@shikijs/compat": "^1.1.7",
@@ -2477,11 +2477,12 @@ export const deployment: NavMenuConstant = {
url: undefined,
items: [
{ name: 'Overview', url: '/guides/deployment/branching' },
{ name: 'GitHub integration', url: '/guides/deployment/branching/github-integration' },
{ name: 'Branching via GitHub', url: '/guides/deployment/branching/github-integration' },
{
name: 'Branching 2.0 (Alpha)',
url: '/guides/deployment/branching/branching-2',
name: 'Branching via dashboard',
url: '/guides/deployment/branching/dashboard',
},
{
name: 'Working with branches',
url: '/guides/deployment/branching/working-with-branches',
@@ -22,15 +22,13 @@ Now set up the database schema. You can use the "User Management Starter" quicks
<TabPanel id="dashboard" label="Dashboard">
1. Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard.
2. Click **User Management Starter**.
2. Click **User Management Starter** under the **Community > Quickstarts** tab.
3. Click **Run**.
<Admonition type="note">
You can pull the database schema down to your local project by running the `db pull` command. Read the [local development docs](/docs/guides/cli/local-development#link-your-project) for detailed instructions.
{/* TODO: discuss */}
```bash
supabase link --project-ref <project-id>
# You can get <project-id> from your project's dashboard URL: https://supabase.com/dashboard/project/<project-id>
@@ -2,4 +2,4 @@ This tutorial demonstrates how to build a basic user management app. The app aut
- [Supabase Database](/docs/guides/database) - a Postgres database for storing your user data and [Row Level Security](/docs/guides/auth#row-level-security) so data is protected and users can only access their own information.
- [Supabase Auth](/docs/guides/auth) - allow users to sign up and log in.
- [Supabase Storage](/docs/guides/storage) - users can upload a profile photo.
- [Supabase Storage](/docs/guides/storage) - allow users to upload a profile photo.
@@ -95,7 +95,7 @@ To use Google's pre-built signin buttons:
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Android_ or _iOS_ depending on the mobile operating system your app is built for.
- For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
- You will have a different set of SHA-1 certificate fingerprint for testing locally and going to production. Make sure to add both to the Google Cloud Console. and add all of the Client IDs to Supabase dashboard.
- You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard.
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store.
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
@@ -188,4 +188,4 @@ Do reach out to support if you have any concerns around this change.
- [Supabase - Get started for free](https://supabase.com)
- [Supabase JS Client](https://github.com/supabase/supabase-js)
- [LinkedIn Developer Dashboard](https://api.LinkedIn.com/apps)
- [LinkedIn Developer Dashboard](https://www.linkedin.com/developers/apps)
+1 -1
View File
@@ -26,7 +26,7 @@ This is made possible if the providers are using JWTs signed with asymmetric key
There are some limitations you should be aware of when using third-party authentication providers with Supabase.
1. The third-party provider must use asymmetrically signed JWTs (exposed as an OIDC Issuer Discovery URL by the third-party authentication provider). Using symmetrically signed JWTs is not possible at this time.
1. The third-party provider must use asymmetrically signed JWTs (exposed as an OIDC Issuer Discovery URL by the third-party authentication provider). The signed JWTs must have a `kid` header parameter to identify which key must be used. Using symmetrically signed JWTs is not possible at this time.
2. The JWT signing keys from the third-party provider are stored in the configuration of your project, and are checked for changes periodically. If you are rotating your keys (when supported) allow up to 30 minutes for the change to be picked up.
3. It is not possible to disable Supabase Auth at this time.
@@ -12,5 +12,5 @@ Supabase branches create separate environments that spin off from your main proj
- **Separate Environments**: Each branch is a separate environment with its own Supabase instance and API credentials.
- **Preview Branches**: You can create multiple Preview Branches for testing.
- **Persistent Branches**: Persistent branches are long-lived branches. They aren't automatically paused or deleted due to non-inactivity or merging.
- **GitHub integration and Branching 2.0**: You can use branching with the GitHub integration, or use Branching 2.0 to manage your branches without Git (in Feature Preview).
- **Managing Branches**: You can create, review, and merge branches either automatically via our [GitHub integration](/docs/guides/deployment/branching/github-integration) or directly [through the dashboard](/docs/guides/deployment/branching/dashboard) (currently in beta). All branches show up in the branches page in the dashboard, regardless of how they were created.
- **Data-less**: New branches do not start with any data from your main project. This is meant to better protect your sensitive production data. To start your branches with data, you can use a [seed file](/docs/guides/deployment/branching/github-integration#seeding) if using the GitHub integration.
@@ -1,36 +1,36 @@
---
title: 'Branching 2.0 (Alpha)'
subtitle: 'Create and manage branches without using Git'
title: 'Branching via the dashboard'
subtitle: 'Create, manage, review, and merge branches directly in the dashboard'
---
Branching 2.0 allows you to create and manage Supabase branches without connecting to a Git repository. This is useful for quick testing, prototyping, or when you prefer to manage your database changes outside of Git.
You can create, manage, review, and merge Supabase branches directly via the dashboard. This is useful for quick testing, prototyping, or when you prefer to work in a no-code way. You can also connect a Supabase branch to a GitHub branch at a later time if needed.
<Admonition type="note" label="Public Alpha">
Branching 2.0 is currently in public alpha. Features and functionality may change.
Branch management via the dashboard is currently in public alpha. Features and functionality may change.
</Admonition>
## How Branching 2.0 works
## How Branching works
With Branching 2.0, you can do the following directly from the Supabase dashboard:
You can do the following directly from the Supabase dashboard:
- Create preview branches
- Make changes to your public schema or edge functions
- Merge these changes back into production when ready
- Pull in updates from production
## Enable Branching 2.0
## Enable branch management via the dashboard
Branching 2.0 is available as a Feature Preview. To enable Branching 2.0 in the Supabase Dashboard:
This functionality is currently in beta and requires opting in. To opt in you must enable the feature preview:
1. Open the user menu by clicking on your user icon in the top right.
1. Select **Branching 2.0**.
1. Select **Branching via dashboard**.
1. Click **Enable feature**.
## Creating a branch
Once you've enabled Branching 2.0, you can create a new branch:
Once you've enabled the feature, you can create a new branch:
1. Click the arrows next to the branch name in the top menu bar. (The top menu bar has the format `YOUR_ORGANIZATION / YOUR_PROJECT / CURRENT_BRANCH_NAME`.)
2. Click `Create branch`.
@@ -48,6 +48,9 @@ jobs:
- wait
if: ${{ needs.wait.outputs.status == 'success' }}
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
steps:
- uses: supabase/setup-cli@v1
with:
@@ -18,8 +18,8 @@ After developing your project and deciding it's Production Ready, you should run
- Enable replication on tables containing sensitive data by enabling Row Level Security (RLS) and setting row security policies:
- Go to the Authentication > Policies page in the Supabase Dashboard to enable RLS and create security policies.
- Go to the Database > Publications page in the Supabase Dashboard to manage replication tables.
- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement)
- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for your database.
- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) (see: [dashboard](https://supabase.com/dashboard/project/_/auth/policies))
- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for your database (see: [dashboard](https://supabase.com/dashboard/project/_/database/settings#network-restrictions)).
- Ensure that your Supabase Account is protected with multi-factor authentication (MFA).
- If using a GitHub signin, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app.
- If using email+password signin, set up [MFA for your Supabase account](https://supabase.com/docs/guides/platform/multi-factor-authentication#enable-mfa).
@@ -31,4 +31,5 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform."
- Outgoing connections to ports `25` and `587` are not allowed.
- Serving of HTML content is only supported with [custom domains](/docs/reference/cli/supabase-domains) (Otherwise `GET` requests that return `text/html` will be rewritten to `text/plain`).
- Web Worker API (or Node `vm` API) are not available.
- Static files cannot be deployed using the API flag. You need to build them with [Docker on the CLI](/docs/guides/functions/quickstart#step-6-deploy-to-production).
- Node Libraries that require multithreading are not supported. Examples: [`libvips`](https://github.com/libvips/libvips), [sharp](https://github.com/lovell/sharp).
+2 -1
View File
@@ -122,7 +122,8 @@ Before deploying, ensure the Wasm module is bundled with your function by defini
<Admonition type="note">
You will need update Supabase CLI to 2.7.0 or higher for the `static_files` support.
- You will need update Supabase CLI to 2.7.0 or higher for the `static_files` support.
- Static files cannot be deployed using the `--use-api` API flag. You need to build them with [Docker on the CLI](/docs/guides/functions/quickstart#step-6-deploy-to-production).
</Admonition>
@@ -17,11 +17,11 @@ If you get stuck while working through this guide, refer to the [full example on
## Building the app
Let's start building the Next.js app from scratch.
Start building the Next.js app from scratch.
### Initialize a Next.js app
We can use [`create-next-app`](https://nextjs.org/docs/getting-started) to initialize an app called `supabase-nextjs`:
Use [`create-next-app`](https://nextjs.org/docs/getting-started) to initialize an app called `supabase-nextjs`:
<Tabs
scrollable
@@ -54,8 +54,7 @@ Then install the Supabase client library: [supabase-js](https://github.com/supab
npm install @supabase/supabase-js
```
And finally we want to save the environment variables in a `.env.local`.
Create a `.env.local` file at the root of the project, and paste the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
Save the environment variables in a `.env.local` file at the root of the project, and paste the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
```bash .env.local
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
@@ -65,13 +64,13 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
### App styling (optional)
An optional step is to update the CSS file `app/globals.css` to make the app look nice.
You can find the full contents of this file [here](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/nextjs-user-management/app/globals.css).
You can find the full contents of this file [in the example repository](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/nextjs-user-management/app/globals.css).
### Supabase Server-Side Auth
Next.js is a highly versatile framework offering pre-rendering at build time (SSG), server-side rendering at request time (SSR), API routes, and middleware edge-functions.
To better integrate with the framework, we've created the `@supabase/ssr` package for Server-Side Auth. It has all the functionalities to quickly configure your Supabase project to use cookies for storing user sessions. See the [Next.js Server-Side Auth guide](/docs/guides/auth/server-side/nextjs) for more information.
To better integrate with the framework, we've created the `@supabase/ssr` package for Server-Side Auth. It has all the functionalities to quickly configure your Supabase project to use cookies for storing user sessions. Read the [Next.js Server-Side Auth guide](/docs/guides/auth/server-side/nextjs) for more information.
Install the package for Next.js.
@@ -184,11 +183,11 @@ Since Server Components can't write cookies, you need middleware to refresh expi
- Passing the refreshed Auth token to Server Components through `request.cookies.set`, so they don't attempt to refresh the same token themselves.
- Passing the refreshed Auth token to the browser, so it replaces the old token. This is done with `response.cookies.set`.
You could also add a matcher, so that the middleware only runs on route that access Supabase. For more information, check out this [documentation](https://nextjs.org/docs/app/building-your-application/routing/middleware#matching-paths).
You could also add a matcher, so that the middleware only runs on routes that access Supabase. For more information, read [the Next.js matcher documentation](https://nextjs.org/docs/app/api-reference/file-conventions/middleware#matcher).
<Admonition type="danger">
Be careful when protecting pages. The server gets the user session from the cookies, which can be spoofed by anyone.
Be careful when protecting pages. The server gets the user session from the cookies, which anyone can spoof.
Always use `supabase.auth.getUser()` to protect pages and user data.
@@ -358,17 +357,17 @@ meta="name=app/login/page.tsx"
</Tabs>
Navigate to `http://localhost:3000/login`. You should see your login form, but it's not yet hooked up to the actual login function. Next, you need to create the login/signup actions. They will:
Next, you need to create the login/signup actions to hook up the form to the function. Which does the following:
- Retrieve the user's information.
- Send that information to Supabase as a signup request, which in turns will send a confirmation email.
- Send that information to Supabase as a signup request, which in turns sends a confirmation email.
- Handle any error that arises.
<Admonition type="caution">
Note that cookies is called before any calls to Supabase, which opts fetch calls out of Next.js's caching. This is important for authenticated data fetches, to ensure that users get access only to their own data.
The `cookies` method is called before any calls to Supabase, which takes fetch calls out of Next.js's caching. This is important for authenticated data fetches, to ensure that users get access only to their own data.
See the Next.js docs to learn more about [opting out of data caching](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching).
Read the Next.js docs to learn more about [opting out of data caching](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching).
</Admonition>
@@ -382,6 +381,8 @@ See the Next.js docs to learn more about [opting out of data caching](https://ne
<TabPanel id="js" label="JavaScript">
Create the `action.js` file in the `app/login` folder, which contains the login and signup functions and the `error/page.jsx` file, and displays an error message if the login or signup fails.
<$CodeTabs>
```js name=app/login/actions.js
@@ -409,7 +410,6 @@ export async function login(formData) {
}
revalidatePath('/', 'layout')
redirect('/account')
}
export async function signup(formData) {
@@ -427,7 +427,6 @@ export async function signup(formData) {
}
revalidatePath('/', 'layout')
redirect('/account')
}
```
@@ -443,6 +442,8 @@ export default function ErrorPage() {
<TabPanel id="ts" label="TypeScript">
Create the `action.ts` file in the `app/login` folder, which contains the login and signup functions and the `error/page.tsx` file, which displays an error message if the login or signup fails.
<$CodeTabs>
<$CodeSample
@@ -461,35 +462,31 @@ meta="name=app/error/page.tsx"
</TabPanel>
When you enter your email and password, you will receive an email with the title **Confirm Your Signup**. Congrats 🎉!!!
</Tabs>
### Email template
Change the email template to support a server-side authentication flow.
Before we proceed, let's change the email template to support sending a token hash:
Before proceeding, change the email template to support support a server-side authentication flow that sends a token hash:
- Go to the [Auth templates](/dashboard/project/_/auth/templates) page in your dashboard.
- Select `Confirm signup` template.
- Select the **Confirm signup** template.
- Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`.
<Admonition type="tip">
Did you know? You could also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
**Did you know?** You can also customize other emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
</Admonition>
### Confirmation endpoint
As we are working in a server-side rendering (SSR) environment, it is necessary to create a server endpoint responsible for exchanging the `token_hash` for a session.
As you are working in a server-side rendering (SSR) environment, you need to create a server endpoint responsible for exchanging the `token_hash` for a session.
In the following code snippet, we perform the following steps:
The code performs the following steps:
- Retrieve the code sent back from the Supabase Auth server using the `token_hash` query parameter.
- Exchange this code for a session, which we store in our chosen storage mechanism (in this case, cookies).
- Finally, we redirect the user to the `account` page.
- Retrieves the code sent back from the Supabase Auth server using the `token_hash` query parameter.
- Exchanges this code for a session, which you store in your chosen storage mechanism (in this case, cookies).
- Finally, redirects the user to the `account` page.
<Tabs
scrollable
@@ -505,7 +502,6 @@ In the following code snippet, we perform the following steps:
```js name=app/auth/confirm/route.js
import { NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
// Creating a handler to a GET request to route /auth/confirm
@@ -562,9 +558,9 @@ meta="name=app/auth/confirm/route.ts"
### Account page
After a user is signed in we can allow them to edit their profile details and manage their account.
After a user signs in, allow them to edit their profile details and manage their account.
Let's create a new component for that called `AccountForm` within the `app/account` folder.
Create a new component for that called `AccountForm` within the `app/account` folder.
<Tabs
scrollable
@@ -589,7 +585,6 @@ export default function AccountForm({ user }) {
const [fullname, setFullname] = useState(null)
const [username, setUsername] = useState(null)
const [website, setWebsite] = useState(null)
const [avatar_url, setAvatarUrl] = useState(null)
const getProfile = useCallback(async () => {
try {
@@ -609,7 +604,6 @@ export default function AccountForm({ user }) {
setFullname(data.full_name)
setUsername(data.username)
setWebsite(data.website)
setAvatarUrl(data.avatar_url)
}
} catch (error) {
alert('Error loading user data!')
@@ -631,7 +625,6 @@ export default function AccountForm({ user }) {
full_name: fullname,
username,
website,
avatar_url,
updated_at: new Date().toISOString(),
})
if (error) throw error
@@ -680,7 +673,7 @@ export default function AccountForm({ user }) {
<div>
<button
className="button primary block"
onClick={() => updateProfile({ fullname, username, website, avatar_url })}
onClick={() => updateProfile({ fullname, username, website })}
disabled={loading}
>
{loading ? 'Loading ...' : 'Update'}
@@ -719,7 +712,7 @@ meta="name=app/account/account-form.tsx"
</Tabs>
Create an account page for the `AccountForm` component we just created
Create an account page for the `AccountForm` component you just created
<Tabs
scrollable
@@ -769,7 +762,7 @@ meta="name=app/account/page.tsx"
### Sign out
Let's create a route handler to handle the signout from the server side. Make sure to check if the user is logged in first!
Create a route handler to handle the sign out from the server side, making sure to check if the user is logged in first.
<Tabs
scrollable
@@ -829,13 +822,15 @@ meta="name=app/auth/signout/route.ts"
### Launch!
Now that we have all the pages, route handlers and components in place, let's run this in a terminal window:
Now you have all the pages, route handlers, and components in place, run the following in a terminal window:
```bash
npm run dev
```
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
And then open the browser to [localhost:3000/login](http://localhost:3000/login) and you should see the completed app.
When you enter your email and password, you will receive an email with the title **Confirm Your Signup**. Congrats 🎉!!!
## Bonus: Profile photos
@@ -844,7 +839,7 @@ photos and videos.
### Create an upload widget
Let's create an avatar widget for the user so that they can upload a profile photo. We can start by creating a new component:
Create an avatar widget for the user so that they can upload a profile photo. Start by creating a new component:
<Tabs
scrollable
@@ -970,7 +965,7 @@ meta="name=app/account/avatar.tsx"
### Add the new widget
And then we can add the widget to the `AccountForm` component:
Then add the widget to the `AccountForm` component:
<Tabs
scrollable
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff. Show more