chore: scaffolds out developer-portfolio template

This commit is contained in:
PatrikKozak
2023-09-13 10:27:20 -04:00
parent 8ca67d5aaa
commit 245c9ac60b
115 changed files with 13829 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
MONGODB_URI=mongodb://127.0.0.1/payload-example-custom-server
PAYLOAD_SECRET=PAYLOAD_CUSTOM_SERVER_EXAMPLE_SECRET_KEY
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_PUBLIC_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET
COOKIE_DOMAIN=localhost
REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY
PAYLOAD_SEED=false
PAYLOAD_DROP_DATABASE=false
ENABLE_PAYLOAD_CLOUD=false

View File

@@ -0,0 +1,9 @@
module.exports = {
root: true,
extends: ['plugin:@next/next/recommended', '@payloadcms'],
ignorePatterns: ['**/payload-types.ts'],
plugins: ['prettier'],
rules: {
'prettier/prettier': 'error',
},
}

View File

@@ -0,0 +1,9 @@
build
dist
node_modules
package-lock.json
.env
.next
.vercel
src/media
.DS_Store

View File

@@ -0,0 +1 @@
**/payload-types.ts

View File

@@ -0,0 +1,8 @@
module.exports = {
printWidth: 100,
parser: "typescript",
semi: false,
singleQuote: true,
trailingComma: "all",
arrowParens: "avoid",
};

View File

@@ -0,0 +1,76 @@
# Payload Developer Portfolio Example
This example demonstrates a complete professional portfolio application using [Payload](https://github.com/payloadcms/payload) and NextJS in a single Express server.
## Highlights
- 💪 **Batteries-Included**
- Design beautiful pages, describe portfolio projects, and build forms dynamically without writing code
- 🔎 **SEO-Friendly**
- Includes [SEO plugin](https://github.com/payloadcms/plugin-seo) integration to author and preview page metadata
- 🪭 **Customization-Friendly**
- Light/dark mode, [@shadcn/ui](https://ui.shadcn.com/) integration, prebuilt animations, and modular CMS "blocks" encourage extension and re-use
- 🏎️ **Performance-Focused**
- Uses React Server Components, App Router, and `next/image` to optimize Web Vitals metrics
- 🦯 **Accessibility-Minded**
- Navigation, contrast, dialogs, and forms built with [WCAG 2](https://www.w3.org/WAI/standards-guidelines/wcag/) in mind
## Quick Start
### Prerequisites
- [Node](https://nodejs.org/en) 18.x or newer
- [MongoDB](https://www.mongodb.com/try/download/community)
### Setup
1. First, clone the repo
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
1. Next `yarn && yarn seed` to start the app and seed it with example data
1. Now `open http://localhost:3000` to view the site
That's it! Changes made in `./src` will be reflected in your app. See the [Development](#development) section for more details.
### Editing Content
To access the Admin interface, where you can edit content:
1. Go to `http://localhost:3000/admin`
1. Login with `dev@payloadcms.com` / `test`
## How it works
When you use Payload, you plug it into _**your**_ Express server. That's a fundamental difference between Payload and other application frameworks. It means that when you use Payload, you're technically _adding_ Payload to _your_ app, and not building a "Payload app".
One of the strengths of this pattern is that it lets you do powerful things like integrate your Payload instance directly with your front-end. This will allow you to host Payload alongside a fully dynamic, CMS-integrated website or app on a single, combined server—while still getting all of the benefits of a headless CMS.
## Development
To spin up this example locally, follow the [Quick Start](#quick-start).
### Seed
On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates:
- An admin user with email `dev@payloadcms.com`, password `test`,
- A `home` page with Profile CTA, project grid, and contact form
- Example header and profile data
- Example media assets
- Example portfolio projects
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
## Production
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
1. First, invoke the `payload build` script by running `yarn build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
1. Then, run `yarn serve` to run Node in production and serve Payload from the `./build` directory.
### Deployment
The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also choose to self-host your app, check out the [Deployment](https://payloadcms.com/docs/production/deployment) docs for more details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "stone",
"cssVariables": true
},
"aliases": {
"components": "src/app/_components",
"utils": "src/utilities"
}
}

View File

@@ -0,0 +1,36 @@
import fs from 'fs'
import path from 'path'
// Run this script to eject the front-end from this template
// This will remove all template-specific files and directories
// See `yarn eject` in `package.json` for the exact command
// See `./README.md#eject` for more information
const files = ['./next.config.js', './next-env.d.ts']
const directories = ['./src/app']
const eject = async (): Promise<void> => {
files.forEach(file => {
fs.unlinkSync(path.join(__dirname, file))
})
directories.forEach(directory => {
fs.rm(path.join(__dirname, directory), { recursive: true }, err => {
if (err) throw err
})
})
// create a new `./src/server.ts` file
// use contents from `./src/server.default.ts`
const serverFile = path.join(__dirname, './src/server.ts')
const serverDefaultFile = path.join(__dirname, './src/server.default.ts')
fs.copyFileSync(serverDefaultFile, serverFile)
// remove `'plugin:@next/next/recommended', ` from `./.eslintrc.js`
const eslintConfigFile = path.join(__dirname, './.eslintrc.js')
const eslintConfig = fs.readFileSync(eslintConfigFile, 'utf8')
const updatedEslintConfig = eslintConfig.replace(`'plugin:@next/next/recommended', `, '')
fs.writeFileSync(eslintConfigFile, updatedEslintConfig, 'utf8')
}
eject()

View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -0,0 +1,18 @@
require('dotenv').config()
const imageDomains = ['localhost']
if (process.env.PAYLOAD_PUBLIC_SERVER_URL) {
const { hostname } = new URL(process.env.PAYLOAD_PUBLIC_SERVER_URL)
imageDomains.push(hostname)
}
// eslint-disable-next-line no-console
console.log('allowed image domains:', imageDomains.join(', '))
module.exports = {
reactStrictMode: true,
swcMinify: true,
images: {
domains: imageDomains,
},
}

View File

@@ -0,0 +1,5 @@
{
"watch": ["server.ts"],
"exec": "ts-node --project tsconfig.server.json src/server.ts",
"ext": "js ts"
}

View File

@@ -0,0 +1,75 @@
{
"name": "payload-developer-portfolio-template",
"description": "Payload developer portfolio template.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_SEED=false PAYLOAD_DROP_DATABASE=false PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon -V",
"seed": "rm -rf src/media .next && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc --project tsconfig.server.json",
"build:next": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NEXT_BUILD=true node dist/server.js",
"build": "cross-env NODE_ENV=production yarn build:payload && yarn build:server && yarn copyfiles && yarn build:next",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"eject": "yarn remove next react react-dom @next/eslint-plugin-next && ts-node eject.ts",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
"lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src"
},
"dependencies": {
"@hookform/resolvers": "^3.2.0",
"@payloadcms/plugin-form-builder": "^1.0.15",
"@payloadcms/plugin-seo": "^1.0.14-canary.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"dotenv": "^8.2.0",
"escape-html": "^1.0.3",
"express": "^4.17.1",
"lucide-react": "^0.263.1",
"next": "^13.4.19",
"next-themes": "^0.2.1",
"nodemailer": "^6.9.4",
"payload": "1.15.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.6",
"zod": "^3.21.4"
},
"devDependencies": {
"@next/eslint-plugin-next": "^13.1.6",
"@payloadcms/eslint-config": "^0.0.2",
"@types/escape-html": "^1.0.2",
"@types/express": "^4.17.9",
"@types/node": "18.11.3",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"autoprefixer": "^10.4.14",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"nodemon": "^2.0.22",
"postcss": "^8.4.27",
"prettier": "^2.7.1",
"tailwindcss": "^3.3.3",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,15 @@
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
path {
fill: #0F0F0F;
}
@media (prefers-color-scheme: dark) {
path {
fill: white;
}
}
</style>
<path d="M120.59 8.5824L231.788 75.6142V202.829L148.039 251.418V124.203L36.7866 57.2249L120.59 8.5824Z" />
<path d="M112.123 244.353V145.073L28.2114 193.769L112.123 244.353Z" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,5 @@
import type { Access } from 'payload/config'
export const loggedIn: Access = ({ req: { user } }) => {
return Boolean(user)
}

View File

@@ -0,0 +1,17 @@
import type { Access } from 'payload/config'
export const publishedOrLoggedIn: Access = ({ req: { user } }) => {
if (user) {
return true
}
return {
or: [
{
_status: {
equals: 'published',
},
},
],
}
}

View File

@@ -0,0 +1,54 @@
import { Metadata, ResolvingMetadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Media } from '../../payload-types'
import { ContentLayout } from '../_components/content/contentLayout'
import { fetchPage } from '../_utils/api'
import { parsePreviewOptions } from '../_utils/preview'
interface LandingPageProps {
params: {
slug: string
}
searchParams: Record<string, string>
}
export async function generateMetadata(
{ searchParams, params }: LandingPageProps,
parent?: ResolvingMetadata,
): Promise<Metadata> {
const defaultTitle = (await parent)?.title?.absolute
const options = parsePreviewOptions(searchParams)
const page = await fetchPage(params.slug, options)
const title = page?.meta?.title || defaultTitle
const description = page?.meta?.description || 'A portfolio of work by a digital professional.'
const images = []
if (page?.meta?.image) {
images.push((page.meta.image as Media).url)
}
return {
title,
description,
openGraph: {
title,
description,
images,
},
}
}
const LandingPage = async ({ params, searchParams }: LandingPageProps) => {
const { slug } = params
const options = parsePreviewOptions(searchParams)
const page = await fetchPage(slug, options)
if (!page?.layout) {
notFound()
}
return <ContentLayout layout={page.layout} className="mt-16" />
}
export default LandingPage

View File

@@ -0,0 +1,17 @@
import { cn } from '../../utilities'
export const PayloadLogo = ({ className = '' }: { className?: string }) => (
<svg
width="28"
height="32"
viewBox="0 0 28 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
className={cn('fill-primary dark:fill-white')}
d="M11.6014 31.0443C11.6248 31.0386 11.6482 31.033 11.6715 31.0267C11.6665 31.0015 11.6608 30.9763 11.6557 30.9511C11.6583 30.8989 11.664 30.8466 11.664 30.7944C11.664 26.6039 11.664 22.4141 11.664 18.2237C11.664 18.1513 11.6526 18.0782 11.6469 18.0058C11.614 18.0046 11.5812 18.0033 11.5477 18.0021C11.3878 18.0902 11.2267 18.1752 11.0687 18.2671C10.1917 18.7777 9.31655 19.2927 8.43761 19.8008C7.61301 20.2773 6.78273 20.7445 5.95813 21.2204C4.74999 21.918 3.54374 22.6194 2.33749 23.3194C1.74037 23.6657 1.14451 24.0145 0.5 24.3903C1.0788 24.7297 1.61399 25.0363 2.14224 25.3542C3.53363 26.1922 4.92186 27.0352 6.31198 27.875C7.72106 28.7268 9.12888 29.5806 10.5411 30.4273C10.8887 30.6357 11.2501 30.8202 11.6052 31.0153L11.5919 31.0279L11.6014 31.0443ZM27.4905 25.5425C27.4937 25.4694 27.5 25.3964 27.5 25.3234C27.5 19.8958 27.5 14.4683 27.5 9.04072C27.5 8.96769 27.4924 8.89466 27.4886 8.82163C27.4741 8.804 27.4589 8.787 27.4444 8.76937C27.3616 8.71271 27.2814 8.65101 27.1948 8.59939C26.3386 8.08377 25.4818 7.5694 24.6256 7.05504C23.5565 6.41288 22.4873 5.77134 21.4182 5.12855C19.8771 4.20182 18.3391 3.27068 16.7948 2.34961C15.5323 1.59664 14.2509 0.87326 13.0048 0.093848C12.7527 -0.0641749 12.6086 0.00318942 12.4304 0.107069C11.2216 0.809043 10.0198 1.52235 8.81105 2.2237C8.06354 2.65747 7.30403 3.07173 6.55589 3.50488C5.42925 4.15774 4.30894 4.82132 3.18484 5.47796C2.68755 5.76882 2.18774 6.05402 1.65633 6.36062C1.73847 6.42358 1.78144 6.46324 1.8301 6.49283C2.32422 6.78747 2.82088 7.07708 3.31311 7.37424C4.13897 7.87349 4.95977 8.38155 5.78753 8.87766C6.5938 9.36117 7.40892 9.83083 8.21645 10.3137C8.96902 10.7639 9.71336 11.2272 10.4653 11.6786C11.5142 12.3082 12.5675 12.9296 13.6171 13.5579C14.4404 14.0509 15.2492 14.5684 16.0883 15.033C16.343 15.174 16.4087 15.3125 16.4081 15.5795C16.4011 20.934 16.4024 26.2885 16.4024 31.643C16.4024 31.752 16.4024 31.8602 16.4024 32C17.4684 31.3855 18.4901 30.7975 19.5106 30.2082C20.8704 29.4232 22.2302 28.6387 23.5887 27.8511C24.8758 27.1051 26.1604 26.3552 27.4457 25.6067L27.4905 25.5425Z"
/>
</svg>
)

View File

@@ -0,0 +1,35 @@
import { FC } from 'react'
import { Block, BlockProps } from '../ui/block'
import { PayloadLink, PayloadLinkType } from './link'
import { RichText } from './richText'
interface ContentBlockFields extends BlockProps {
richText?: unknown
enableLink?: boolean
link?: PayloadLinkType
}
interface ContentBlockProps {
contentFields: ContentBlockFields[]
}
export const ContentBlock: FC<ContentBlockProps> = ({ contentFields }) => {
return (
<>
{contentFields.map(({ richText, size, id, enableLink, link }) => {
let content = <RichText content={richText} />
if (enableLink) {
content = <PayloadLink link={link}>{content}</PayloadLink>
}
return (
<Block size={size} key={id} asChild={enableLink}>
{content}
</Block>
)
})}
</>
)
}

View File

@@ -0,0 +1,65 @@
import { FC } from 'react'
import { Form, Page, Profile, Project } from '../../../payload-types'
import { cn } from '../../../utilities'
import { ContentBlock } from './contentBlock'
import { FormBlock } from './formBlock'
import { MediaBlock } from './mediaBlock'
import { MediaContentBlock } from './mediaContentBlock'
import { ProfileCTABlock } from './profileCTABlock'
import { ProjectGridBlock } from './projectGridBlock'
interface ContentLayoutProps {
layout?: Page['layout']
profile?: Profile
className?: string
}
export const ContentLayout: FC<ContentLayoutProps> = ({ layout, profile, className }) => {
let hasMedia = false
return (
<div className={cn('w-full grid grid-cols-6 lg:gap-20', className)}>
{layout?.map((block, index) => {
let element = null
switch (block.blockType) {
case 'content':
element = <ContentBlock contentFields={block.contentFields} key={block.id} />
break
case 'mediaBlock':
element = (
<MediaBlock
containerClassName="h-[70vw] lg:h-[348px]"
mediaFields={block.mediaFields}
key={block.id}
priority={!hasMedia}
/>
)
hasMedia = true
break
case 'profile-cta':
element = <ProfileCTABlock profile={profile} key={block.id} />
break
case 'projectGrid':
element = (
<ProjectGridBlock
projects={block.project as Project[]}
key={block.id}
priority={!hasMedia}
/>
)
break
case 'form':
element = <FormBlock intro={block.richText} form={block.form as Form} key={block.id} />
break
case 'mediaContent':
element = <MediaContentBlock {...block} priority={!hasMedia} key={block.id} />
hasMedia = true
break
}
return element
})}
</div>
)
}

View File

@@ -0,0 +1,225 @@
'use client'
import React, { FC, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Data } from 'payload/dist/admin/components/forms/Form/types'
import { Form as FormTypes } from '../../../payload-types'
import { serverUrl } from '../../_utils/api'
import { Block } from '../ui/block'
import { Button } from '../ui/button'
import { Dialog, DialogContent } from '../ui/dialog'
import { Input } from '../ui/input'
import { Textarea } from '../ui/textarea'
import { RichText } from './richText'
type ErrorType = {
status: string
message: string
}
export type FormBlockProps = {
form: FormTypes
intro?: unknown
}
export interface FormField {
name: string
label?: string
width?: number
defaultValue?: string
required?: boolean
id?: string
blockName?: string
blockType: 'text' | 'textarea' | 'email' | 'message'
message?: {
[k: string]: unknown
}[]
}
function groupFieldsByRow(form: FormTypes): FormTypes['fields'][] {
const rows: FormTypes['fields'][] = []
let currentRow: FormTypes['fields'] = []
let currentRowWidth = 0
for (const field of form.fields || []) {
const fieldWidth = field.blockType !== 'message' ? field.width || 100 : 100 // Assuming a default width of 100% if not specified
// Check if adding this field to the current row would exceed 100%
if (currentRowWidth + fieldWidth > 100) {
// End the current row and start a new one
rows.push(currentRow)
currentRow = []
currentRowWidth = 0
}
// Add the field to the current row and update the width
currentRow.push(field)
currentRowWidth += fieldWidth
}
// If there are any remaining fields in the current row, add them to the rows
if (currentRow.length > 0) {
rows.push(currentRow)
}
return rows
}
export const FormBlock: FC<FormBlockProps> = props => {
const [dialogOpen, setDialogOpen] = useState(false)
const {
form: formFromProps,
form: { id: formID, submitButtonLabel },
intro,
} = props
const formMethods = useForm()
const {
register,
handleSubmit,
formState: { isDirty },
} = formMethods
const [error, setError] = useState<ErrorType | undefined>()
const [isLoading, setIsLoading] = useState(false)
const onSubmit = async (data: Data) => {
setIsLoading(true) // Set loading state when submitting
const dataToSend = Object.entries(data).map(([name, value]) => ({
field: name,
value,
}))
try {
const req = await fetch(`${serverUrl}/api/form-submissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json', // Correct typo: "Content-Types" to "Content-Type"
},
body: JSON.stringify({
form: formID,
submissionData: dataToSend,
}),
})
const res = await req.json()
if (req.status >= 400) {
setError({
status: res.status,
message: res.errors?.[0] || 'Internal Server Error', // Use optional chaining
})
}
setIsLoading(false) // Clear loading state
if (props.form.confirmationType === 'message') {
setDialogOpen(true)
} else if (props.form.redirect) {
window.location.href = props.form.redirect.url
} else {
// eslint-disable-next-line no-console
console.error('No post-submit action defined for form')
}
} catch (error) {
setError({
status: 'Error',
message: 'An error occurred while submitting the form.',
})
setIsLoading(false) // Clear loading state
}
}
return (
<Block className="w-full flex flex-col m-auto" key={formID}>
{intro && <RichText content={intro} className="w-full" />}
<form onSubmit={handleSubmit(onSubmit)} className="w-full flex flex-col gap-4">
{groupFieldsByRow(formFromProps).map((row, index) => (
<div key={index}>
{row.map((field, index) => {
if (field.blockType === 'message') {
return <RichText content={field.message} className="-mb-4" key={index} />
}
let pattern
if (field.blockType === 'email') {
pattern = {
value: /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i,
message: 'Please enter a valid email address.',
}
}
const props = {
id: `${formID}-${field.name}`,
...register(field.name, { required: field.required, pattern }),
}
let content
switch (field.blockType) {
case 'text':
case 'email':
content = <Input type="text" {...props} />
break
case 'textarea':
content = <Textarea {...props} />
break
default:
content = null
break
}
return (
<>
<style
key={`style-${index}`}
dangerouslySetInnerHTML={{
__html: `
#formField__${props.id} {
width: 100%;
}
@media (min-width: 1024px) {
#formField__${props.id} {
width: ${field.width}%;
}
}
`,
}}
/>
<div
id={`formField__${props.id}`}
key={index}
className="inline-flex flex-col gap-2 mt-4 first:mt-0 content-box pb-4 last:pb-0 lg:pb-0 pr-0 lg:pr-5 last:pr-0 "
>
<label htmlFor={props.id} className="text-sm text-primary">
{field.label}
</label>
{content}
{formMethods.formState.errors[field.name]?.message && (
<div className="text-sm text-red-500 mt-2">
{formMethods.formState.errors[field.name].message as string}
</div>
)}
</div>
</>
)
})}
</div>
))}
<Button type="submit" disabled={isLoading || !isDirty} className="max-w-[80px] mt-8">
{submitButtonLabel}
</Button>
{error && <div className="mt-4">{error.message}</div>}
</form>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="w-[80vw] max-w-lg">
<div className="flex flex-col gap-4 items-center">
<RichText content={props.form.confirmationMessage} />
<Button onClick={() => setDialogOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</Block>
)
}

View File

@@ -0,0 +1,39 @@
import { AnchorHTMLAttributes, ReactNode } from 'react'
import Link, { LinkProps } from 'next/link'
import { Header } from '../../../payload-types'
export type PayloadLinkType = Header['navItems'][0]['link']
export const PayloadLink = ({
link,
children,
className,
}: {
link: PayloadLinkType
children?: ReactNode
className?: string
}) => {
let props: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement> = {
href: '/',
}
if (link.newTab) {
props.target = '_blank'
}
if (link.type === 'reference') {
const { reference } = link
const prefix = reference.relationTo === 'pages' ? '/' : `/${reference.relationTo}/`
const suffix = (reference.value as { slug: string }).slug
props.href = `${prefix}${suffix}`
} else {
props.href = link.url
}
return (
<Link {...props} className={className}>
{children || link.label}
</Link>
)
}

View File

@@ -0,0 +1,119 @@
import { FC } from 'react'
import { cva } from 'class-variance-authority'
import Image from 'next/image'
import { Media } from '../../../payload-types'
import { cn } from '../../../utilities'
import { Block, BlockProps } from '../ui/block'
import { MediaDialog } from './mediaDialog'
const mediaBlockCaptionVariants = cva('flex w-full mt-1 text-primary', {
variants: {
captionSize: {
small: 'text-sm md:text-base lg:text-xl',
large: 'text-xl lg:leading-7',
},
},
defaultVariants: {
captionSize: 'small',
},
})
interface MediaBlockFields extends BlockProps {
media: string | Media
mediaFit?: 'contain' | 'cover'
}
interface MediaBlockProps {
mediaFields: MediaBlockFields[]
lightbox?: boolean
className?: string
containerClassName?: string
imageClassName?: string
captionClassName?: string
priority?: boolean
captionSize?: 'small' | 'large'
}
const sizesMap = {
full: '1080px',
half: '540px',
oneThird: '360px',
twoThirds: '720px',
}
export const MediaBlock: FC<MediaBlockProps> = ({
mediaFields,
className,
containerClassName,
imageClassName,
captionClassName,
lightbox = true,
priority,
captionSize,
}) => {
return (
<>
{mediaFields.map(({ media, size, mediaFit = 'cover' }) => {
const mediaInfo = media as Media
const base = (
<>
<div
className={cn(
'flex-0 flex relative',
mediaFit === 'cover' ? 'h-full w-auto' : 'h-auto w-full',
)}
>
<Image
className={cn('overflow-hidden rounded-3xl flex-1', imageClassName)}
src={mediaInfo.url}
alt={mediaInfo.alt}
width={mediaInfo.width}
height={mediaInfo.height}
sizes={`(min-width: 1024px) ${sizesMap[size]}, 90vw`}
style={{
objectFit: mediaFit ?? 'cover',
}}
priority={priority}
/>
</div>
</>
)
const containerClassNames = cn(
'flex flex-col relative w-full text-left',
containerClassName,
mediaFit === 'cover' && 'h-full w-auto',
)
const caption = mediaInfo.alt && (
<p className={mediaBlockCaptionVariants({ captionSize, className: captionClassName })}>
{mediaInfo.alt}
</p>
)
if (lightbox) {
return (
<Block size={size} className={cn('flex-col', className)} key={mediaInfo.id}>
<MediaDialog
className={containerClassNames}
mediaFit={mediaFit}
triggerContent={base}
caption={caption}
mediaInfo={mediaInfo}
/>
</Block>
)
}
return (
<Block size={size} className={cn('flex-col', className)} key={mediaInfo.id}>
{base}
{caption}
</Block>
)
})}
</>
)
}

View File

@@ -0,0 +1,95 @@
import { FC } from 'react'
import { Media } from '../../../payload-types'
import { ContentBlock } from './contentBlock'
import { PayloadLink, PayloadLinkType } from './link'
import { MediaBlock } from './mediaBlock'
type LayoutSize = 'oneThird' | 'twoThirds' | 'half' | 'full'
interface MediaContentFields {
alignment?: 'contentMedia' | 'mediaContent'
mediaSize?: LayoutSize
media: Media | string
mediaFit?: 'contain' | 'cover'
richText?: unknown
enableLink?: boolean
link?: PayloadLinkType
}
export interface MediaContentBlockProps {
mediaContentFields?: MediaContentFields[]
priority?: boolean
}
const complimentSizes: Record<LayoutSize, LayoutSize> = {
oneThird: 'twoThirds',
twoThirds: 'oneThird',
half: 'half',
full: 'full',
}
export const MediaContentBlock: FC<MediaContentBlockProps> = ({ mediaContentFields, priority }) => {
return (
<>
{mediaContentFields?.map(
({ alignment, mediaSize, media, richText, link, enableLink, mediaFit }) => {
const mediaBlock = (
<MediaBlock
priority={priority}
className="mb-10 md:mb-16 lg:mb-0"
mediaFields={[
{
size: mediaSize,
media,
mediaFit,
},
]}
/>
)
const contentBlock = (
<ContentBlock
contentFields={[
{
size: complimentSizes[mediaSize],
richText,
},
]}
/>
)
let content
if (alignment === 'contentMedia') {
content = (
<>
{contentBlock}
{mediaBlock}
</>
)
} else {
content = (
<>
{mediaBlock}
{contentBlock}
</>
)
}
if (enableLink) {
content = (
<PayloadLink
link={link}
className="col-span-6 grid grid-cols-6 mt-8 lg:mt-0 lg:gap-20"
>
{content}
</PayloadLink>
)
}
return content
},
)}
</>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { FC, useState } from 'react'
import Image from 'next/image'
import { cn } from '../../../utilities'
import { Dialog, DialogContent, DialogTrigger } from '../ui/dialog'
interface MediaDialogProps {
className?: string
mediaFit?: 'contain' | 'cover'
triggerContent: React.ReactNode
caption?: React.ReactNode
mediaInfo: {
id: string
url?: string
alt?: string
width?: number
height?: number
}
}
export const MediaDialog: FC<MediaDialogProps> = ({
className,
mediaFit,
triggerContent,
caption,
mediaInfo,
}) => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger
className={cn(className, 'first:mt-8 first:md:mt-12 first:lg:mt-0 mb-1 lg:mb-0')}
>
{triggerContent}
{mediaFit === 'contain' && caption}
</DialogTrigger>
{mediaFit === 'cover' && caption}
<DialogContent
onClick={() => setDialogOpen(false)}
showCloseButton
variant="fullscreen"
className="flex justify-center flex-col gap-2 items-start"
style={{
maxHeight: `${mediaInfo.height}px`,
maxWidth: `${mediaInfo.width}px`,
}}
>
<div
className="w-full h-full relative "
style={{
maxWidth: `${mediaInfo.width}px`,
height: `min(${(mediaInfo.height / mediaInfo.width) * 100}vw, 80vh)`,
}}
>
<Image
id={`${mediaInfo.id}-lightbox`}
className="object-contain"
src={mediaInfo.url}
alt={mediaInfo.alt}
fill
sizes="90vw"
/>
</div>
{mediaInfo.alt && (
<div
className="w-full text-center pl-2 sm:pl-0 text-primary text-sm lg:text-xl"
style={{ maxWidth: `${mediaInfo.width}px` }}
>
{mediaInfo.alt}
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,129 @@
import { cva } from 'class-variance-authority'
import Image from 'next/image'
import Link from 'next/link'
import { Media, Profile } from '../../../payload-types'
import { Block } from '../ui/block'
import { RichText } from './richText'
import { SocialIcons } from './socialIcons'
const containerVariants = cva('w-full bg-box/40 rounded-[1.25rem] max-w-[1080px]', {
variants: {
variant: {
compact:
'px-3 sm:px-14 mt-[3.31rem] mb-[3.75rem] sm:mt-[10.5rem] sm:mb-[5.25rem] py-4 flex flex-col sm:flex-row justify-between items-center',
full: 'lg:px-28 py-12 mt-52 lg:mt-40',
},
},
})
const nameVariants = cva('w-full max-w-[417px] mx-auto', {
variants: {
variant: {
compact: 'text-2xl font-medium leading-[1.875rem]',
full: 'mb-2 lg:mb-0 text-2xl lg:text-5xl font-bold leading-[28px]',
},
},
})
const imageContainerVariants = cva('flex-shrink-0 block', {
variants: {
variant: {
compact: 'w-[80px] h-[80px] lg:w-[75px] lg:h-[75px] relative items-center justify-center',
full: 'w-[230px] h-[230px] lg:w-[300px] lg:h-[300px] absolute lg:relative z-20 -top-52 lg:-top-0',
},
},
})
const topContentVariants = cva('flex', {
variants: {
variant: {
compact: 'justify-evenly items-center',
full: 'flex-col lg:flex-row items-center lg:justify-evenly relative',
},
},
})
const imageProps = {
compact: {
sizes: '(max-width: 768px) 20vw, 5vw',
},
full: {
sizes: '(max-width: 768px) 59vw, 21vw',
},
}
const textContainerVariants = cva('text-foreground text-sm leading-6 rounded-xl ', {
variants: {
variant: {
compact: 'ml-3 lg:ml-8',
full: 'pt-12 lg:pt-0 px-12 lg:px-0 lg:pl-24 w-full lg:col-span-3',
},
},
})
const titleVariants = cva(
'text-base text-primary leading-tight mt-2 w-full max-w-[417px] mx-auto',
{
variants: {
variant: {
compact: '',
full: 'font-medium lg:text-xl',
},
},
},
)
const socialIconVariants = cva('lg:mt-0 sm:ml-4 lg:ml-0', {
variants: {
variant: {
compact: 'mt-4 lg:mt-0 gap-9',
full: 'mt-8 lg:mt-8 justify-center',
},
},
defaultVariants: {
variant: 'full',
},
})
export const ProfileCTABlock = ({
profile,
variant = 'full',
}: {
profile: Profile
variant?: 'compact' | 'full'
}) => {
return (
<Block size="full" className="w-full">
<div className={containerVariants({ variant })}>
<div className={topContentVariants({ variant })}>
{profile.profileImage && (
<Link href="/" className={imageContainerVariants({ variant })}>
<Image
priority
className="rounded-full"
fill
{...imageProps[variant]}
alt={(profile.profileImage as Media).alt}
src={(profile.profileImage as Media).url}
/>
</Link>
)}
<div className={textContainerVariants({ variant })}>
<h1 className={nameVariants({ variant })}>{profile.name}</h1>
{profile.location && variant === 'full' && (
<h2 className="leading-6 text-base lg:mt-2 w-full max-w-[417px] mx-auto">
{profile.location}
</h2>
)}
{profile.title && <h3 className={titleVariants({ variant })}>{profile.title}</h3>}
{profile.aboutMe && variant === 'full' && (
<RichText content={profile.aboutMe} className="mt-6 w-full mx-auto max-w-[417px]" />
)}
</div>
</div>
<SocialIcons className={socialIconVariants({ variant })} profile={profile} />
</div>
</Block>
)
}

View File

@@ -0,0 +1,11 @@
import Link from 'next/link'
import { Button } from '../../ui/button'
export const BackButton = () => {
return (
<Link href="/">
<Button>Back to Profile</Button>
</Link>
)
}

View File

@@ -0,0 +1,39 @@
import { FC } from 'react'
import { Profile, Project } from '../../../../payload-types'
import { FadeInContent } from '../../ui/fadeInContent'
import { ContentLayout } from '../contentLayout'
import { ProfileCTABlock } from '../profileCTABlock'
import { RichText } from '../richText'
import { BackButton } from './backButton'
import { ProjectDetailsHeadline } from './projectDetailsHeadline'
import { ProjectHero } from './projectHero'
export interface ProjectDetailsProps {
project: Project
profile: Profile
}
export const ProjectDetails: FC<ProjectDetailsProps> = ({ project, profile }) => {
return (
<>
<ProfileCTABlock profile={profile} variant="compact" />
<section className="lg:mb-20 flex flex-col lg:gap-12 lg:block lg:after:table lg:after:clear-both lg:after:float-none">
<FadeInContent className="relative z-10 delay-100 order-2 lg:order-none lg:float-right lg:mb-0">
<ProjectHero project={project} />
</FadeInContent>
<FadeInContent className="order-1 lg:order-none">
<ProjectDetailsHeadline project={project} />
</FadeInContent>
<FadeInContent className="relative z-0 delay-200 order-3 lg:order-none lg:max-w-[455px]">
<RichText content={project.description} />
</FadeInContent>
</section>
<ContentLayout profile={profile} layout={project.layout} className="mb-20" />
<div className="text-center lg:text-left ">
<BackButton />
</div>
</>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import { FC } from 'react'
import { Project } from '../../../../payload-types'
import { formatMonth } from '../../../_utils/format'
import { ProjectRoles } from './projectRole'
interface ProjectDetailsHeadlineProps {
project: Project
}
export const ProjectDetailsHeadline: FC<ProjectDetailsHeadlineProps> = ({
project,
}: ProjectDetailsHeadlineProps) => {
return (
<div className="relative z-0 text-foreground lg:pb-8 lg:pr-16 w-full lg:w-1/2">
<h1 className="font-bold leading-[30-px] text-2xl lg:text-5xl">{project.title}</h1>
{project.startDate && (
<p className="leading-6 text-base pt-2">
{formatMonth(project.startDate)}
{project.endDate && ` - ${formatMonth(project.endDate)}`}
</p>
)}
<ProjectRoles roles={project.role} />
</div>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
import { FC } from 'react'
import { Project } from '../../../../payload-types'
import { MediaBlock } from '../../../_components/content/mediaBlock'
import { TechnologiesUsed } from './technologiesUsed'
interface ProjectHeroProps {
project: Project
}
export const ProjectHero: FC<ProjectHeroProps> = ({ project }) => {
return (
<div className="relative z-10 lg:pl-20 mt-6 lg:mt-0 flex flex-col items-start lg:items-center justify-center col-span-6 lg:col-span-3 lg:flex-shrink-0 ">
{project.technologiesUsed && <TechnologiesUsed technologies={project.technologiesUsed} />}
<MediaBlock
className="w-full lg:max-w-[545px] mb-10 md:mb-16 lg:mb-0"
mediaFields={[{ media: project.featuredImage, size: 'full' }]}
containerClassName="h-[51vw] sm:h-auto lg:h-[340px]"
imageClassName="h-[51vw] sm:h-auto lg:h-[340px]"
priority
lightbox
/>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { FC } from 'react'
import { Project } from '../../../../payload-types'
interface ProjectRolesProps {
roles: Project['role']
}
export const ProjectRoles: FC<ProjectRolesProps> = ({ roles }) => {
return (
<h4 className="text-primary font-medium leading-tight lg:text-2xl pt-3">
{roles
.map(
role =>
({
uiUxDesigner: 'Lead UI/UX Designer',
frontEndDeveloper: 'Front-End Developer',
backEndDeveloper: 'Back-End Developer',
}[role] ?? ''),
)
.join(', ')}
</h4>
)
}

View File

@@ -0,0 +1,27 @@
import { FC } from 'react'
import { Project, Technology } from '../../../../payload-types'
export interface TechnologiesUsedProps {
technologies: Project['technologiesUsed']
}
export const TechnologiesUsed: FC<TechnologiesUsedProps> = ({ technologies }) => {
return (
<div>
<div className="flex justify-start w-full font-medium lg:text-xl">
<h4>Technologies Used</h4>
</div>
<ul className="flex gap-5 lg:text-xl flex-wrap mt-5 mb-4 lg:mb-6 w-full max-w-[532px] text-primary">
{technologies.map(technology => (
<li
className="border border-primary dark:border-foreground px-5 py-1 lg:py-2 rounded-md "
key={(technology as Technology).id}
>
{(technology as Technology).name}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { FC } from 'react'
import Link from 'next/link'
import { Media, Project } from '../../../payload-types'
import { formatYear } from '../../_utils/format'
import { Block } from '../ui/block'
import { MediaBlock } from './mediaBlock'
const animationDelayOffsets = ['delay-150', 'delay-200']
interface ProjectGridBlockProps {
projects: Project[]
priority?: boolean
}
export const ProjectGridBlock: FC<ProjectGridBlockProps> = ({ projects, priority }) => {
return (
<Block
size="full"
className="bg-transparent lg:mb-20 lg:mt-[2.125rem] flex w-full lg:flex-shrink-0 lg:justify-center"
>
<div className="w-full grid grid-cols-1 lg:grid-cols-2 lg:gap-20 mt-16 lg:mt-0 lg:gap-y-2">
{projects.map(({ id, startDate, slug, title, featuredImage }, index) => (
<Link
href={`/projects/${slug}`}
key={id}
className="relative col-span-1 lg:first:mt-0 mb-14 last:mb-16 lg:mb-8 last:lg:mb-8 hover:-translate-y-1 transition-all"
>
<MediaBlock
lightbox={false}
className={`${animationDelayOffsets[index % 2]}`}
imageClassName="h-[56vw] lg:h-[376px]"
captionSize="large"
priority={priority}
mediaFields={[
{
media: {
...(featuredImage as Media),
width: 500,
height: 376,
alt: `${title}, ${formatYear(startDate)}`,
},
size: 'half',
},
]}
/>
</Link>
))}
</div>
</Block>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import React from 'react'
import { CustomRenderers, Serialize as SerializeContent } from './serialize'
export const RichText: React.FC<{
content: any
customRenderers?: CustomRenderers
className?: string
}> = ({ content, customRenderers, className }) => {
if (!content) {
return null
}
return (
<div className={className}>
<SerializeContent content={content} customRenderers={customRenderers} />
</div>
)
}

View File

@@ -0,0 +1,180 @@
import React, { Fragment } from 'react'
import escapeHTML from 'escape-html'
import Link from 'next/link'
type Node = {
type: string
value?: {
url: string
alt: string
}
children?: Node[]
url?: string
[key: string]: unknown
newTab?: boolean
}
export type CustomRenderers = {
[key: string]: (args: { node: Node; Serialize: SerializeFunction; index: number }) => JSX.Element // eslint-disable-line
}
type SerializeFunction = React.FC<{
content?: Node[]
customRenderers?: CustomRenderers
}>
const isText = (value: any): boolean =>
typeof value === 'object' && value !== null && typeof value.text === 'string'
export const Serialize: SerializeFunction = ({ content, customRenderers }) => {
return (
<Fragment>
{content?.map((node, i) => {
if (isText(node)) {
// @ts-expect-error
let text = <span dangerouslySetInnerHTML={{ __html: escapeHTML(node.text) }} />
if (node.bold) {
text = <strong key={i}>{text}</strong>
}
if (node.code) {
text = <code key={i}>{text}</code>
}
if (node.italic) {
text = <em key={i}>{text}</em>
}
if (node.underline) {
text = (
<span style={{ textDecoration: 'underline' }} key={i}>
{text}
</span>
)
}
if (node.strikethrough) {
text = (
<span style={{ textDecoration: 'line-through' }} key={i}>
{text}
</span>
)
}
return <Fragment key={i}>{text}</Fragment>
}
if (!node) {
return null
}
if (
customRenderers &&
customRenderers[node.type] &&
typeof customRenderers[node.type] === 'function'
) {
return customRenderers[node.type]({ node, Serialize, index: i })
}
switch (node.type) {
case 'br':
return <br key={i} />
case 'h1':
return (
<h1 key={i} className="text-3xl lg:text-4xl mb-2">
<Serialize content={node.children} customRenderers={customRenderers} />
</h1>
)
case 'h2':
return (
<h2 key={i} className="text-2xl font-semibold lg:font-medium lg:text-3xl mb-2">
<Serialize content={node.children} customRenderers={customRenderers} />
</h2>
)
case 'h3':
return (
<h3 key={i} className="text-xl lg:text-2xl mb-2">
<Serialize content={node.children} customRenderers={customRenderers} />
</h3>
)
case 'h4':
return (
<h4 key={i} className="text-lg lg:text-xl mb-2">
<Serialize content={node.children} customRenderers={customRenderers} />
</h4>
)
case 'h5':
return (
<h5 key={i} className="text-base lg:text-lg mb-2">
<Serialize content={node.children} customRenderers={customRenderers} />
</h5>
)
case 'h6':
return (
<h6 key={i} className="text-sm mb-2">
<Serialize content={node.children} customRenderers={customRenderers} />
</h6>
)
case 'quote':
return (
<blockquote key={i}>
<Serialize content={node.children} customRenderers={customRenderers} />
</blockquote>
)
case 'ul':
return (
<ul key={i}>
<Serialize content={node.children} customRenderers={customRenderers} />
</ul>
)
case 'ol':
return (
<ol key={i}>
<Serialize content={node.children} customRenderers={customRenderers} />
</ol>
)
case 'li':
return (
<li key={i}>
<Serialize content={node.children} customRenderers={customRenderers} />
</li>
)
case 'link':
return (
<Link
href={escapeHTML(node.url)}
key={i}
{...(node.newTab
? {
target: '_blank',
rel: 'noopener noreferrer',
}
: {})}
>
<Serialize content={node.children} customRenderers={customRenderers} />
</Link>
)
default:
return (
<p key={i} className="text-sm leading-6 mb-4">
<Serialize content={node.children} customRenderers={customRenderers} />
</p>
)
}
})}
</Fragment>
)
}

View File

@@ -0,0 +1,55 @@
import { FC, Suspense } from 'react'
import { EnvelopeOpenIcon } from '@radix-ui/react-icons'
import { GithubIcon, LinkedinIcon, TwitterIcon } from 'lucide-react'
import { Profile } from '../../../payload-types'
import { cn } from '../../../utilities'
import { fetchProfile } from '../../_utils/api'
import { SocialLink } from '../ui/socialLink'
interface SocialIconsContentProps {
className?: string
profile: Profile
}
const SocialIconsContent = async ({ className = '' }) => {
const profile = await fetchProfile()
return (
<div className={cn('flex lg:max-w-[300px] gap-8 items-center', className)}>
{profile.socialLinks?.github && (
<SocialLink
href={profile.socialLinks.github}
icon={<GithubIcon width={24} height={24} aria-label="Github profile link" />}
target="_blank"
/>
)}
{profile.socialLinks?.linkedin && (
<SocialLink
href={profile.socialLinks.linkedin}
icon={<LinkedinIcon width={24} height={24} aria-label="LinkedIn profile link" />}
target="_blank"
/>
)}
{profile.socialLinks?.email && (
<SocialLink
href={`mailto:${profile.socialLinks.email}`}
icon={<EnvelopeOpenIcon width={24} height={24} aria-label="Email link" />}
/>
)}
{profile.socialLinks?.twitter && (
<SocialLink
href={profile.socialLinks.twitter}
icon={<TwitterIcon width={24} height={24} aria-label="Twitter link" />}
target="_blank"
/>
)}
</div>
)
}
export const SocialIcons: FC<SocialIconsContentProps> = props => (
<Suspense fallback={<div className={cn('lg:max-w-[300px] w-full h-[25px]', props.className)} />}>
{/* @ts-ignore */}
<SocialIconsContent {...props} />
</Suspense>
)

View File

@@ -0,0 +1,34 @@
import { FC } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { Profile } from '../../../payload-types'
import { PayloadLogo } from '../../_assets/payloadLogo'
import { SocialIcons } from '../content/socialIcons'
import { ThemeToggle } from './themeToggle'
interface FooterProps {
profile: Profile
}
export const Footer: FC<FooterProps> = ({ profile }) => {
return (
<div className="p-12 mt-12 lg:mt-20 flex flex-col lg:flex-row lg:justify-between items-center w-full max-w-[1300px] text-primary">
<div className="flex justify-center items-center gap-4 ">
<PayloadLogo />
<p>
Website made with{' '}
<Link href="https://payloadcms.com" className="underline" target="_payload">
Payload
</Link>
</p>
</div>
<div className="flex flex-col lg:flex-row items-center gap-8">
<div className="w-full mt-6 lg:mt-0 lg:max-w-[175px]">
<SocialIcons profile={profile} className="justify-center lg:justify-end" />
</div>
<ThemeToggle />
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import Image from 'next/image'
import Link from 'next/link'
import { Header, Media, Profile } from '../../../payload-types'
import { PayloadLink } from '../content/link'
import { SkipToMainContentLink } from './skipToMainContent'
const HeaderLinks = ({ header }: { header: Header }) => {
return (
<>
{header.navItems?.map(({ id, link }) => (
<PayloadLink link={link} key={id} />
))}
</>
)
}
export const NavBar = ({ profile, header }: { profile: Profile; header: Header }) => {
return (
<>
<div className="fixed z-50 bg-background/50 backdrop-blur-[20px] w-full flex justify-center">
<SkipToMainContentLink />
<div className="w-full max-w-[1300px] h-16 flex items-center justify-center md:justify-between md:px-8 content-box">
{profile.profileImage && (
<Link
href="/"
className="hidden sm:block items-center w-10 h-10 my-4"
style={{ position: 'relative' }}
>
<Image
src={(profile.profileImage as Media).url}
className="rounded-full"
alt={(profile.profileImage as Media).alt}
priority
fill
sizes="(min-width: 640px) 10vw, (min-width: 1024px) 5vw"
style={{ objectFit: 'cover' }}
/>
</Link>
)}
<nav className="flex gap-6 lg:gap-8 w-full max-w-[378px] lg:w-auto justify-center md:justify-end text-base items-center text-primary">
<HeaderLinks header={header} />
</nav>
</div>
</div>
<div className="pb-16" />
</>
)
}

View File

@@ -0,0 +1,16 @@
import { FC } from 'react'
import { Button } from '../ui/button'
export const SkipToMainContentLink: FC = () => {
return (
<a
href="#main-content"
className={
'z-10 bg-primary text-primary-content absolute top-0 p-3 m-3 -translate-y-56 focus:translate-y-0'
}
>
<Button> Skip to Main Content</Button>
</a>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import * as React from 'react'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { cn } from '../../../utilities'
import { Button } from '../ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdownMenu'
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex gap-4" asChild>
<Button variant="outline">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0 dark:hidden" />
<Moon className="h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 hidden dark:inline" />
Toggle theme
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-primary-foreground text-primary">
<DropdownMenuItem
className={cn('px-12 cursor-pointer', {
'bg-primary text-primary-foreground': theme === 'light',
})}
onClick={() => setTheme('light')}
>
Light
</DropdownMenuItem>
<DropdownMenuItem
className={cn('px-12 cursor-pointer', {
'bg-primary text-primary-foreground': theme === 'dark',
})}
onClick={() => setTheme('dark')}
>
Dark
</DropdownMenuItem>
<DropdownMenuItem
className={cn('px-12 cursor-pointer', {
'bg-primary text-primary-foreground': theme === 'system',
})}
onClick={() => setTheme('system')}
>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,58 @@
@keyframes breatheLeft {
0%, 100% {
transform: scale(0.8);
}
50% {
transform: scale(1);
}
}
@keyframes breatheRight {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(0.8);
}
}
.breatheAnimation {
animation: breatheLeft 30s infinite;
animation-timing-function: ease-in-out;
}
.circleLeft {
width: 113vw;
height: 113vw;
position: absolute;
top: 60%;
left: -28vw;
flex-shrink: 0;
border-radius: 113vw;
background: radial-gradient(50% 50.00% at 50% 50.00%, rgba(165, 243, 252, 0.08) 0%, rgba(217, 70, 239, 0.00) 100%) hsl(var(--background));
}
.circleRight {
width: 120vw;
height: 120vw;
position: absolute;
right: -40vw;
top: -30vw;
flex-shrink: 0;
border-radius: 120vw;
background: radial-gradient(50% 50.00% at 50% 50.00%, rgba(219, 39, 119, 0.14) 0%, rgba(220, 38, 38, 0.00) 100%) hsl(var(--background));
animation-name: breatheRight;
}
@media (min-width: 1024px) {
.circleLeft {
top: 33%;
left: -56vw;
}
.circleRight {
top: -60vw;
right: -80vw;
}
}

View File

@@ -0,0 +1,12 @@
import styles from './backdrop.module.css'
export const Backdrop = () => {
return (
<div
className={`fixed top-0 left-0 flex w-screen h-screen z-0 overflow-hidden opacity-0 dark:opacity-100`}
>
<div className={`${styles.breatheAnimation} ${styles.circleLeft}`} />
<div className={`${styles.breatheAnimation} ${styles.circleRight}`} />
</div>
)
}

View File

@@ -0,0 +1,37 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, VariantProps } from 'class-variance-authority'
import { cn } from '../../../utilities'
import { FadeInContent } from './fadeInContent'
const blockVariants = cva('flex col-span-6 justify-center lg:justify-start', {
variants: {
size: {
oneThird: 'lg:col-span-2',
half: 'lg:col-span-3',
twoThirds: 'lg:col-span-4',
full: 'lg:col-span-6',
},
},
defaultVariants: {
size: 'full',
},
})
export interface BlockProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof blockVariants> {
size?: 'oneThird' | 'twoThirds' | 'half' | 'full'
asChild?: boolean
}
const Block = React.forwardRef<HTMLDivElement, BlockProps>(
({ className, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : FadeInContent
return <Comp className={cn(blockVariants({ size, className }))} ref={ref} {...props} />
},
)
Block.displayName = 'Block'
export { Block, blockVariants }

View File

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

View File

@@ -0,0 +1,124 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cva } from 'class-variance-authority'
import { cn } from '../../../utilities'
const dialogContentVariants = cva(
'p-4 lg:p-12 fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg flex flex-col justify-center items-center',
{
variants: {
variant: {
bounded: 'max-w-screen-2xl p-6',
fullscreen: 'w-[100vw] h-auto',
},
},
defaultVariants: {
variant: 'bounded',
},
},
)
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = ({ className, ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props} />
)
DialogPortal.displayName = DialogPrimitive.Portal.displayName
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 bg-box/70 dark:bg-box/70 backdrop-blur-[20px] z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
variant?: 'bounded' | 'fullscreen'
showCloseButton?: boolean
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, variant = 'bounded', showCloseButton = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={dialogContentVariants({ variant, className })}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className={cn(
'absolute right-2 lg:right-4 -bottom-6 sm:-bottom-0 -md:-bottom-6 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none text-primary',
)}
>
Close
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,183 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '../../../utilities'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}

View File

@@ -0,0 +1,46 @@
'use client'
import React, { AllHTMLAttributes, FC, PropsWithChildren, useEffect, useRef } from 'react'
export const FadeInContent: FC<PropsWithChildren<AllHTMLAttributes<HTMLDivElement>>> = ({
children,
className = '',
...props
}) => {
const contentRef = useRef(null)
useEffect(() => {
const currentRef = contentRef.current
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
entry.target.classList.remove('opacity-0', 'translate-y-10')
entry.target.classList.add(
'opacity-100',
'transition-all',
'duration-500',
'ease-in-out',
'translate-y-0',
)
}
},
{
threshold: 0, // Adjust this value to control how much of the element must be visible before the fade-in starts
},
)
if (contentRef.current) {
observer.observe(contentRef.current)
}
return () => {
observer.unobserve(currentRef)
}
}, [])
return (
<div ref={contentRef} className={`opacity-0 translate-y-10 ${className}`} {...props}>
{children}
</div>
)
}

View File

@@ -0,0 +1,167 @@
import * as React from 'react'
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import { cn } from '../../../utilities'
import { Label } from './label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
)
},
)
FormItem.displayName = 'FormItem'
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = 'FormLabel'
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = 'FormControl'
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
})
FormDescription.displayName = 'FormDescription'
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = 'FormMessage'
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import Link from 'next/link'
interface SocialLinkProps {
href: string
icon: React.ReactNode
target?: string
}
export const SocialLink = ({ icon, href, target }: SocialLinkProps) => {
return (
<Link
href={href}
className="text-primary active:text-primary/50 hover:-translate-y-1 transition-all"
target={target}
>
{icon}
</Link>
)
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '../../../utilities'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex h-[150px] w-full rounded-md border border-input bg-box px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -0,0 +1,9 @@
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,82 @@
import type { Header, Page, Profile, Project } from '../../payload-types'
import type { DraftOptions } from './preview'
export const serverUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL ?? 'http://localhost:3000'
const initPreviewRequest = (init: RequestInit, qs: URLSearchParams, token: string): void => {
if (!token) {
throw new Error('No token provided when attempting to preview content')
}
qs.append('draft', 'true')
init.cache = 'no-store'
init.headers = {
cookie: `payload-token=${token};path=/;HttpOnly`,
}
}
export const fetchProfile = async (): Promise<Profile> => {
const url = `${serverUrl}/api/globals/profile?locale=en`
const profile: Profile = await fetch(url, {
cache: 'force-cache',
next: { tags: ['global.profile'] },
}).then(res => {
return res.json()
})
return profile
}
export const fetchHeader = async (): Promise<Header> => {
const url = `${serverUrl}/api/globals/header?locale=en`
const header: Header = await fetch(url, {
cache: 'force-cache',
next: { tags: ['global.header'] },
}).then(res => res.json())
return header
}
type FetchPageOptions = DraftOptions
export const fetchPage = async (
slug: string,
options: FetchPageOptions,
): Promise<Page | undefined> => {
const qs = new URLSearchParams({ 'where[slug][equals]': slug })
const init: RequestInit = { next: { tags: [`pages/${slug}`] } }
if (options.draft) {
initPreviewRequest(init, qs, options.payloadToken)
}
const url = `${serverUrl}/api/pages?${qs.toString()}`
const page: Page = await fetch(url, init)
.then(res => res.json())
.then(res => res?.docs?.[0])
return page
}
type FetchProjectOptions = DraftOptions
export const fetchProject = async (
slug: string,
options: FetchProjectOptions,
): Promise<Project> => {
const qs = new URLSearchParams(`where[slug][equals]=${slug}`)
const init: RequestInit = {}
if (options.draft) {
initPreviewRequest(init, qs, options.payloadToken)
} else {
init.next = { tags: [`projects/${slug}`] }
}
const url = `${serverUrl}/api/projects?${qs.toString()}`
const project: Project = await fetch(url, init)
.then(res => res.json())
.then(res => res?.docs?.[0])
return project
}

View File

@@ -0,0 +1,13 @@
export const formatMonth = (dateRaw: string): string => {
const formatter = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' })
const formattedDate = formatter.format(new Date(dateRaw))
return formattedDate
}
export const formatYear = (dateRaw: string): string => {
const formatter = new Intl.DateTimeFormat('en-US', { year: 'numeric' })
const formattedDate = formatter.format(new Date(dateRaw))
return formattedDate
}

View File

@@ -0,0 +1,15 @@
import { cookies } from 'next/headers'
export interface DraftOptions {
draft?: boolean
payloadToken?: string
}
export const parsePreviewOptions = (
searchParams: Record<string, string | undefined>,
): DraftOptions => {
const draft = searchParams.preview === 'true'
const payloadToken = cookies().get('payload-token')?.value
return { draft, payloadToken }
}

View File

@@ -0,0 +1,34 @@
import { revalidatePath, revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
export async function GET(request: NextRequest): Promise<unknown> {
const secret = request.nextUrl.searchParams.get('secret')
const path = request.nextUrl.searchParams.get('path')
const tag = request.nextUrl.searchParams.get('tag')
if (secret !== process.env.REVALIDATION_KEY) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
}
if (!(path || tag)) {
return NextResponse.json({ message: 'Missing path or tag param' }, { status: 400 })
}
if (path) {
revalidatePath(path)
// eslint-disable-next-line no-console
console.log('revalidated path', path)
} else if (tag) {
revalidateTag(tag)
// eslint-disable-next-line no-console
console.log('revalidated tag', tag)
if (tag.startsWith('projects/')) {
revalidatePath('/') // also revalidate the home page, which has the project grid
// eslint-disable-next-line no-console
console.log('revalidated project dependencies')
}
}
return NextResponse.json({ revalidated: true, now: Date.now() })
}

View File

@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--primary: 273 62% 57%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--ring: 24 5.4% 63.9%;
--radius: 0.5rem;
--box: 0 0% 77%;
}
.dark {
--background: 0 0% 9%;
--foreground: 0 0% 100%;
--muted: 0 0% 15% / 0.2;
--muted-foreground: 0 0% 100%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 12 6.5% 15.1%;
--box: 0 0% 15%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
import { Footer } from './_components/siteLayout/footer'
import { NavBar } from './_components/siteLayout/navBar'
import { Backdrop } from './_components/ui/backdrop/backdrop'
import { ThemeProvider } from './_provider/themeProvider'
import { fetchHeader, fetchProfile, serverUrl } from './_utils/api'
import './globals.css'
export async function generateMetadata() {
const profile = await fetchProfile()
return {
metadataBase: new URL(serverUrl),
title: `Portfolio | ${profile.name}`,
description: 'My professional portfolio featuring past projects and contact info.',
}
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const [profile, header] = await Promise.all([fetchProfile(), fetchHeader()])
return (
<html lang="en" className={`dark ${inter.className}`}>
<body className="w-full overflow-x-hidden">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Backdrop />
<div className="relative z-20 min-h-screen flex flex-col items-center">
<NavBar profile={profile} header={header} />
<div
className="flex flex-col w-full max-w-[1080px] px-7 lg:px-8 xl:px-0 justify-center"
id="main-content"
>
<main>{children}</main>
</div>
<Footer profile={profile} />
</div>
</ThemeProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,11 @@
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find requested resource</p>
<Link href="/">Return Home</Link>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import React from 'react'
import { Metadata, ResolvingMetadata } from 'next'
import { Media } from '../payload-types'
import { ContentLayout } from './_components/content/contentLayout'
import { fetchPage, fetchProfile } from './_utils/api'
import { parsePreviewOptions } from './_utils/preview'
interface LandingPageProps {
searchParams: Record<string, string>
}
export async function generateMetadata(
{ searchParams }: LandingPageProps,
parent?: ResolvingMetadata,
): Promise<Metadata> {
const defaultTitle = (await parent)?.title?.absolute
const options = parsePreviewOptions(searchParams)
const page = await fetchPage('home', options)
const title = page?.meta?.title || defaultTitle
const description = page?.meta?.description || 'A portfolio of work by a digital professional.'
const images = []
if (page?.meta?.image) {
images.push((page.meta.image as Media).url)
}
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
images,
},
}
}
export default async function LandingPage({ searchParams }: LandingPageProps) {
const options = parsePreviewOptions(searchParams)
const [page, profile] = await Promise.all([fetchPage('home', options), fetchProfile()])
return <ContentLayout profile={profile} layout={page.layout} />
}

View File

@@ -0,0 +1,59 @@
import { Metadata, ResolvingMetadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Media } from '../../../payload-types'
import { ProjectDetails } from '../../_components/content/projectDetails/projectDetails'
import { fetchProfile, fetchProject } from '../../_utils/api'
import { parsePreviewOptions } from '../../_utils/preview'
interface ProjectPageProps {
params: {
slug: string
}
searchParams: Record<string, string>
}
export async function generateMetadata(
{ params, searchParams }: ProjectPageProps,
parent?: ResolvingMetadata,
): Promise<Metadata> {
const [project, previousTitle] = await Promise.all([
fetchProject(params.slug, parsePreviewOptions(searchParams)),
(await parent)?.title.absolute,
])
const images: string[] = []
if (project?.meta?.image) {
images.push((project.meta.image as Media).url)
} else if (project?.featuredImage) {
images.push((project.featuredImage as Media).url)
}
const title = project?.meta?.title || project?.title || previousTitle
const description = project?.meta?.description || 'Details on a portoflio project.'
return {
title,
description,
openGraph: {
title,
description,
images,
type: 'article',
modifiedTime: project.updatedAt,
},
}
}
export default async function ProjectPage({ params, searchParams }: ProjectPageProps) {
const [project, profile] = await Promise.all([
fetchProject(params.slug, parsePreviewOptions(searchParams)),
fetchProfile(),
])
if (!project) {
notFound()
}
return <ProjectDetails project={project} profile={profile} />
}

View File

@@ -0,0 +1,53 @@
import type { Block } from 'payload/types'
import link from '../../fields/link'
export const Content: Block = {
slug: 'content',
fields: [
{
name: 'contentFields',
type: 'array',
fields: [
{
name: 'size',
type: 'select',
defaultValue: 'oneThird',
options: [
{
value: 'oneThird',
label: 'One Third',
},
{
value: 'half',
label: 'Half',
},
{
value: 'twoThirds',
label: 'Two Thirds',
},
{
value: 'full',
label: 'Full',
},
],
},
{
name: 'richText',
type: 'richText',
},
{
name: 'enableLink',
type: 'checkbox',
},
link({
overrides: {
admin: {
condition: (_, { enableLink }) => Boolean(enableLink),
},
},
}),
],
},
],
}

View File

@@ -0,0 +1,24 @@
import type { Block } from 'payload/types'
export const Form: Block = {
slug: 'form',
labels: {
singular: 'Form Block',
plural: 'Form Blocks',
},
graphQL: {
singularName: 'FormBlock',
},
fields: [
{
name: 'richText',
type: 'richText',
},
{
name: 'form',
type: 'relationship',
relationTo: 'forms',
required: true,
},
],
}

View File

@@ -0,0 +1,57 @@
import type { Block } from 'payload/types'
export const MediaBlock: Block = {
slug: 'mediaBlock',
fields: [
{
name: 'mediaFields',
type: 'array',
fields: [
{
name: 'size',
type: 'select',
defaultValue: 'oneThird',
options: [
{
value: 'oneThird',
label: 'One Third',
},
{
value: 'half',
label: 'Half',
},
{
value: 'twoThirds',
label: 'Two Thirds',
},
{
value: 'full',
label: 'Full',
},
],
},
{
name: 'media',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'mediaFit',
type: 'select',
defaultValue: 'cover',
options: [
{
value: 'cover',
label: 'cover',
},
{
value: 'contain',
label: 'contain',
},
],
},
],
},
],
}

View File

@@ -0,0 +1,98 @@
import type { Block } from 'payload/types'
import link from '../../fields/link'
export const MediaContent: Block = {
slug: 'mediaContent',
fields: [
{
name: 'mediaContentFields',
type: 'array',
fields: [
{
type: 'row',
fields: [
{
name: 'alignment',
type: 'select',
defaultValue: 'contentMedia',
options: [
{
label: 'Content + Media',
value: 'contentMedia',
},
{
label: 'Media + Content',
value: 'mediaContent',
},
],
admin: {
description: 'Choose how to align the content for this block.',
width: '50%',
},
},
{
name: 'mediaSize',
type: 'select',
defaultValue: 'half',
options: [
{
value: 'oneThird',
label: 'One Third',
},
{
value: 'half',
label: 'Half',
},
{
value: 'twoThirds',
label: 'Two Thirds',
},
],
admin: {
width: '50%',
},
},
],
},
{
name: 'richText',
type: 'richText',
},
{
name: 'enableLink',
type: 'checkbox',
},
link({
overrides: {
admin: {
condition: (_, { enableLink }) => Boolean(enableLink),
},
},
}),
{
name: 'media',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'mediaFit',
type: 'select',
defaultValue: 'cover',
options: [
{
value: 'cover',
label: 'cover',
},
{
value: 'contain',
label: 'contain',
},
],
},
],
},
],
}

View File

@@ -0,0 +1,3 @@
.profileHeroHeader {
padding: 15px 0;
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import './index.scss'
export const ProfileUIField: React.FC = () => {
return (
<h3 className="profileHeroHeader">
This block will be populated from the{' '}
<a href="/admin/globals/profile" target="_blank">
Profile content
</a>
</h3>
)
}

View File

@@ -0,0 +1,22 @@
import type { Block } from 'payload/types'
import { ProfileUIField } from './CustomBlock'
export const ProfileCTA: Block = {
slug: 'profile-cta',
labels: {
singular: 'Profile Call to Action',
plural: 'Profile Call to Actions',
},
fields: [
{
name: 'profileUI',
type: 'ui',
admin: {
components: {
Field: ProfileUIField,
},
},
},
],
}

View File

@@ -0,0 +1,14 @@
import type { Block } from 'payload/types'
export const ProjectGrid: Block = {
slug: 'projectGrid',
fields: [
{
name: 'project',
type: 'relationship',
relationTo: 'projects',
hasMany: true,
required: true,
},
],
}

View File

@@ -0,0 +1,24 @@
import type { CollectionConfig } from 'payload/types'
import { loggedIn } from '../access/loggedIn'
export const Media: CollectionConfig = {
slug: 'media',
access: {
create: loggedIn,
read: () => true,
update: loggedIn,
delete: loggedIn,
},
upload: {
staticURL: '/media',
staticDir: 'media',
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
}

View File

@@ -0,0 +1,68 @@
import type { CollectionConfig } from 'payload/types'
import { loggedIn } from '../../access/loggedIn'
import { publishedOrLoggedIn } from '../../access/publishedOrLoggedIn'
import { serverUrl } from '../../app/_utils/api'
import { Content } from '../../blocks/Content'
import { Form } from '../../blocks/Form'
import { MediaBlock } from '../../blocks/Media'
import { MediaContent } from '../../blocks/MediaContent'
import { ProfileCTA } from '../../blocks/ProfileCTA'
import { ProjectGrid } from '../../blocks/ProjectGrid'
import formatSlug from '../../utilities/formatSlug'
import { tagRevalidator } from '../../utilities/tagRevalidator'
const formatAppURL = ({ doc }): string => {
const pathToUse = doc.slug === 'home' ? '' : doc.slug
const { pathname } = new URL(`${serverUrl}/${pathToUse}`)
return pathname
}
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
preview: doc => {
return `${serverUrl}${formatAppURL({ doc })}?preview=true`
},
},
versions: {
drafts: true,
},
access: {
read: publishedOrLoggedIn,
create: loggedIn,
update: loggedIn,
delete: loggedIn,
},
hooks: {
afterChange: [tagRevalidator(doc => `pages/${doc.slug}`)],
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
{
name: 'layout',
label: 'Content',
type: 'blocks',
blocks: [Content, Form, MediaBlock, MediaContent, ProfileCTA, ProjectGrid],
},
{
name: 'slug',
label: 'Slug',
type: 'text',
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [formatSlug('title')],
},
},
],
}
export default Pages

View File

@@ -0,0 +1,126 @@
import type { CollectionConfig } from 'payload/types'
import { loggedIn } from '../../access/loggedIn'
import { publishedOrLoggedIn } from '../../access/publishedOrLoggedIn'
import { serverUrl } from '../../app/_utils/api'
import { Content } from '../../blocks/Content'
import { Form } from '../../blocks/Form'
import { MediaBlock } from '../../blocks/Media'
import { MediaContent } from '../../blocks/MediaContent'
import formatSlug from '../../utilities/formatSlug'
import { tagRevalidator } from '../../utilities/tagRevalidator'
export const Projects: CollectionConfig = {
slug: 'projects',
admin: {
useAsTitle: 'title',
preview: doc => {
return `${serverUrl}/projects/${doc.slug}?preview=true`
},
},
versions: {
drafts: true,
},
access: {
read: publishedOrLoggedIn,
create: loggedIn,
update: loggedIn,
delete: loggedIn,
},
hooks: {
afterChange: [tagRevalidator(doc => `projects/${doc.slug}`)],
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Overview',
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'richText',
required: true,
},
{
name: 'technologiesUsed',
type: 'relationship',
relationTo: 'technologies',
hasMany: true,
required: true,
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
],
},
{
label: 'Content',
fields: [
{
name: 'layout',
type: 'blocks',
blocks: [Content, Form, MediaBlock, MediaContent],
},
],
},
],
},
{
name: 'slug',
label: 'Slug',
type: 'text',
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [formatSlug('title')],
},
},
{
name: 'role',
type: 'select',
hasMany: true,
options: [
{
label: 'UI/UX Designer',
value: 'uiUxDesigner',
},
{
label: 'Front-end Developer',
value: 'frontEndDeveloper',
},
{
label: 'Back-end Developer',
value: 'backEndDeveloper',
},
],
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'startDate',
type: 'date',
admin: {
position: 'sidebar',
},
},
{
name: 'endDate',
type: 'date',
admin: {
position: 'sidebar',
},
},
],
}

View File

@@ -0,0 +1,26 @@
import type { CollectionConfig } from 'payload/types'
import { loggedIn } from '../access/loggedIn'
export const Technologies: CollectionConfig = {
slug: 'technologies',
admin: {
useAsTitle: 'name',
},
access: {
create: loggedIn,
read: () => true,
update: loggedIn,
delete: loggedIn,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
width: '25%',
},
},
],
}

View File

@@ -0,0 +1,17 @@
import type { CollectionConfig } from 'payload/types'
export const Users: CollectionConfig = {
slug: 'users',
auth: {
tokenExpiration: 28800, // 8 hours
cookies: {
sameSite: 'none',
secure: true,
domain: process.env.COOKIE_DOMAIN,
},
},
admin: {
useAsTitle: 'email',
},
fields: [],
}

View File

@@ -0,0 +1,138 @@
import type { Field, GroupField } from 'payload/types'
import deepMerge from '../utilities/deepMerge'
export const appearanceOptions = {
primary: {
label: 'Primary Button',
value: 'primary',
},
secondary: {
label: 'Secondary Button',
value: 'secondary',
},
}
export type LinkAppearances = 'primary' | 'secondary'
type LinkType = (options?: {
appearances?: LinkAppearances[] | false
disableLabel?: boolean
overrides?: Partial<GroupField>
}) => Field
const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
let linkResult: Field = {
name: 'link',
type: 'group',
admin: {
hideGutter: true,
...(overrides?.admin || {}),
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
defaultValue: 'reference',
admin: {
layout: 'horizontal',
width: '50%',
},
},
{
name: 'newTab',
label: 'Open in new tab',
type: 'checkbox',
admin: {
width: '50%',
style: {
alignSelf: 'flex-end',
},
},
},
],
},
],
}
let linkTypes: Field[] = [
{
name: 'reference',
label: 'Document to link to',
type: 'relationship',
relationTo: ['pages', 'projects'],
required: true,
maxDepth: 1,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
label: 'Custom URL',
type: 'text',
required: true,
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
},
]
if (!disableLabel) {
linkTypes[0].admin.width = '50%'
linkTypes[1].admin.width = '50%'
linkResult.fields.push({
type: 'row',
fields: [
...linkTypes,
{
name: 'label',
label: 'Label',
type: 'text',
required: true,
admin: {
width: '50%',
},
},
],
})
} else {
linkResult.fields = [...linkResult.fields, ...linkTypes]
}
if (appearances !== false) {
let appearanceOptionsToUse = [appearanceOptions.primary, appearanceOptions.secondary]
if (appearances) {
appearanceOptionsToUse = appearances.map(appearance => appearanceOptions[appearance])
}
linkResult.fields.push({
name: 'appearance',
type: 'select',
defaultValue: 'primary',
options: appearanceOptionsToUse,
admin: {
description: 'Choose how the link should be rendered.',
},
})
}
return deepMerge(linkResult, overrides)
}
export default link

View File

@@ -0,0 +1,27 @@
import type { GlobalConfig } from 'payload/types'
import { loggedIn } from '../access/loggedIn'
import link from '../fields/link'
import { tagRevalidator } from '../utilities/tagRevalidator'
export const Header: GlobalConfig = {
slug: 'header',
access: {
read: () => true,
update: loggedIn,
},
hooks: {
afterChange: [tagRevalidator(() => 'global.header')],
},
fields: [
{
name: 'navItems',
type: 'array',
fields: [
link({
appearances: false,
}),
],
},
],
}

View File

@@ -0,0 +1,65 @@
import type { GlobalConfig } from 'payload/types'
import { loggedIn } from '../access/loggedIn'
import { tagRevalidator } from '../utilities/tagRevalidator'
export const Profile: GlobalConfig = {
slug: 'profile',
access: {
read: () => true,
update: loggedIn,
},
hooks: {
afterChange: [tagRevalidator(() => 'global.profile')],
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'location',
type: 'text',
},
{
name: 'title',
type: 'text',
},
{
name: 'aboutMe',
type: 'richText',
},
{
name: 'profileImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'socialLinks',
type: 'group',
fields: [
{
name: 'github',
label: 'GitHub',
type: 'text',
},
{
name: 'linkedin',
label: 'LinkedIn',
type: 'text',
},
{
name: 'email',
label: 'Email',
type: 'text',
},
{
name: 'twitter',
label: 'Twitter',
type: 'text',
},
],
},
],
}

View File

@@ -0,0 +1,382 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
media: Media;
pages: Page;
projects: Project;
technologies: Technology;
users: User;
forms: Form;
'form-submissions': FormSubmission;
};
globals: {
header: Header;
profile: Profile;
};
}
export interface Media {
id: string;
alt: string;
updatedAt: string;
createdAt: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
width?: number;
height?: number;
}
export interface Page {
id: string;
title: string;
layout?: (
| {
contentFields?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full';
richText?: {
[k: string]: unknown;
}[];
enableLink?: boolean;
link?: {
type?: 'reference' | 'custom';
newTab?: boolean;
reference:
| {
value: string | Page;
relationTo: 'pages';
}
| {
value: string | Project;
relationTo: 'projects';
};
url: string;
label: string;
appearance?: 'primary' | 'secondary';
};
id?: string;
}[];
id?: string;
blockName?: string;
blockType: 'content';
}
| {
richText?: {
[k: string]: unknown;
}[];
form: string | Form;
id?: string;
blockName?: string;
blockType: 'form';
}
| {
mediaFields?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full';
media: string | Media;
mediaFit?: 'cover' | 'contain';
id?: string;
}[];
id?: string;
blockName?: string;
blockType: 'mediaBlock';
}
| {
mediaContentFields?: {
alignment?: 'contentMedia' | 'mediaContent';
mediaSize?: 'oneThird' | 'half' | 'twoThirds';
richText?: {
[k: string]: unknown;
}[];
enableLink?: boolean;
link?: {
type?: 'reference' | 'custom';
newTab?: boolean;
reference:
| {
value: string | Page;
relationTo: 'pages';
}
| {
value: string | Project;
relationTo: 'projects';
};
url: string;
label: string;
appearance?: 'primary' | 'secondary';
};
media: string | Media;
mediaFit?: 'cover' | 'contain';
id?: string;
}[];
id?: string;
blockName?: string;
blockType: 'mediaContent';
}
| {
id?: string;
blockName?: string;
blockType: 'profile-cta';
}
| {
project: string[] | Project[];
id?: string;
blockName?: string;
blockType: 'projectGrid';
}
)[];
slug?: string;
meta?: {
title?: string;
description?: string;
image?: string | Media;
};
updatedAt: string;
createdAt: string;
_status?: 'draft' | 'published';
}
export interface Project {
id: string;
title: string;
description: {
[k: string]: unknown;
}[];
technologiesUsed: string[] | Technology[];
featuredImage?: string | Media;
layout?: (
| {
contentFields?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full';
richText?: {
[k: string]: unknown;
}[];
enableLink?: boolean;
link?: {
type?: 'reference' | 'custom';
newTab?: boolean;
reference:
| {
value: string | Page;
relationTo: 'pages';
}
| {
value: string | Project;
relationTo: 'projects';
};
url: string;
label: string;
appearance?: 'primary' | 'secondary';
};
id?: string;
}[];
id?: string;
blockName?: string;
blockType: 'content';
}
| {
richText?: {
[k: string]: unknown;
}[];
form: string | Form;
id?: string;
blockName?: string;
blockType: 'form';
}
| {
mediaFields?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full';
media: string | Media;
mediaFit?: 'cover' | 'contain';
id?: string;
}[];
id?: string;
blockName?: string;
blockType: 'mediaBlock';
}
| {
mediaContentFields?: {
alignment?: 'contentMedia' | 'mediaContent';
mediaSize?: 'oneThird' | 'half' | 'twoThirds';
richText?: {
[k: string]: unknown;
}[];
enableLink?: boolean;
link?: {
type?: 'reference' | 'custom';
newTab?: boolean;
reference:
| {
value: string | Page;
relationTo: 'pages';
}
| {
value: string | Project;
relationTo: 'projects';
};
url: string;
label: string;
appearance?: 'primary' | 'secondary';
};
media: string | Media;
mediaFit?: 'cover' | 'contain';
id?: string;
}[];
id?: string;
blockName?: string;
blockType: 'mediaContent';
}
)[];
slug?: string;
role: ('uiUxDesigner' | 'frontEndDeveloper' | 'backEndDeveloper')[];
startDate?: string;
endDate?: string;
meta?: {
title?: string;
description?: string;
image?: string | Media;
};
updatedAt: string;
createdAt: string;
_status?: 'draft' | 'published';
}
export interface Technology {
id: string;
name: string;
updatedAt: string;
createdAt: string;
}
export interface Form {
id: string;
title: string;
fields?: (
| {
name: string;
label?: string;
width?: number;
defaultValue?: string;
required?: boolean;
id?: string;
blockName?: string;
blockType: 'text';
}
| {
name: string;
label?: string;
width?: number;
defaultValue?: string;
required?: boolean;
id?: string;
blockName?: string;
blockType: 'textarea';
}
| {
name: string;
label?: string;
width?: number;
required?: boolean;
id?: string;
blockName?: string;
blockType: 'email';
}
| {
message?: {
[k: string]: unknown;
}[];
id?: string;
blockName?: string;
blockType: 'message';
}
)[];
submitButtonLabel?: string;
confirmationType?: 'message' | 'redirect';
confirmationMessage: {
[k: string]: unknown;
}[];
redirect?: {
url: string;
};
emails?: {
emailTo?: string;
cc?: string;
bcc?: string;
replyTo?: string;
emailFrom?: string;
subject: string;
message?: {
[k: string]: unknown;
}[];
id?: string;
}[];
updatedAt: string;
createdAt: string;
}
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface FormSubmission {
id: string;
form: string | Form;
submissionData?: {
field: string;
value: string;
id?: string;
}[];
updatedAt: string;
createdAt: string;
}
export interface Header {
id: string;
navItems?: {
link: {
type?: 'reference' | 'custom';
newTab?: boolean;
reference:
| {
value: string | Page;
relationTo: 'pages';
}
| {
value: string | Project;
relationTo: 'projects';
};
url: string;
label: string;
};
id?: string;
}[];
updatedAt?: string;
createdAt?: string;
}
export interface Profile {
id: string;
name: string;
location?: string;
title?: string;
aboutMe?: {
[k: string]: unknown;
}[];
profileImage?: string | Media;
socialLinks?: {
github?: string;
linkedin?: string;
email?: string;
twitter?: string;
};
updatedAt?: string;
createdAt?: string;
}

View File

@@ -0,0 +1,50 @@
import formBuilder from '@payloadcms/plugin-form-builder'
import seo from '@payloadcms/plugin-seo'
import dotenv from 'dotenv'
import path from 'path'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
import { buildConfig } from 'payload/config'
import { serverUrl } from './app/_utils/api'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { Projects } from './collections/Projects'
import { Technologies } from './collections/Technologies'
import { Users } from './collections/Users'
import { Header } from './globals/Header'
import { Profile } from './globals/Profile'
const plugins = [
formBuilder({
fields: {
payment: false,
checkbox: false,
country: false,
email: true,
message: true,
number: false,
text: true,
textarea: true,
select: false,
state: false,
},
}),
seo({
collections: ['pages', 'projects'],
uploadsCollection: 'media',
}),
]
export default buildConfig({
serverURL: serverUrl || '',
collections: [Media, Pages, Projects, Technologies, Users],
globals: [Header, Profile],
plugins,
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,65 @@
import payload from 'payload'
const confirmationMessage = [
{
children: [
{
text: "Thank you! I'll be reaching out to you shortly.",
},
],
},
]
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export const seedForms = async () => {
const contactForm = await payload.create({
collection: 'forms',
data: {
title: 'Contact Form',
fields: [
{
name: 'firstname',
label: 'First Name',
width: 50,
required: true,
blockType: 'text',
},
{
name: 'lastname',
label: 'Last Name',
width: 50,
required: true,
blockType: 'text',
},
{
name: 'email',
label: 'Email',
width: 100,
required: true,
blockType: 'email',
},
{
name: 'subject',
label: 'Subject',
width: 100,
required: true,
blockType: 'text',
},
{
name: 'message',
label: 'Message',
width: 100,
required: true,
blockType: 'textarea',
},
],
submitButtonLabel: 'Submit',
confirmationType: 'message',
confirmationMessage: confirmationMessage,
},
})
return { contactForm }
}
export type InitialForms = Awaited<ReturnType<typeof seedForms>>

View File

@@ -0,0 +1,59 @@
import payload from 'payload'
import type { InitialMedia } from './media'
export const seedGlobals = async (media: InitialMedia): Promise<void> => {
await payload.updateGlobal({
slug: 'profile',
data: {
name: 'Samantha Smith',
location: 'Portland, OR',
title: 'UI/UX Designer',
profileImage: media.profileImage.id,
socialLinks: {
github: 'https://github.com/payloadcms',
linkedin: 'https://www.linkedin.com/company/payload-cms/',
twitter: 'https://twitter.com/payloadcms',
email: 'info@payloadcms.com',
},
aboutMe: [
{
text: "Samantha Smith is a visionary artist with a passion for pushing boundaries. She crafts captivating visual stories that leave a lasting impact. Her work reflects a perfect blend of innovation and elegance, whether in logo designs that capture a brand's essence or breathtaking illustrations that transport you to distant realms.",
},
],
_status: 'published',
},
})
await payload.updateGlobal({
slug: 'header',
data: {
navItems: [
{
link: {
type: 'custom',
newTab: true,
url: 'https://www.linkedin.com/company/payload-cms/',
label: 'LinkedIn',
},
},
{
link: {
type: 'custom',
newTab: true,
url: 'https://dribbble.com',
label: 'Dribbble',
},
},
{
link: {
type: 'custom',
newTab: true,
url: 'https://instagram.com',
label: 'Instagram',
},
},
],
},
})
}

View File

@@ -0,0 +1,24 @@
import type { Payload } from 'payload'
import { seedForms } from './forms'
import { seedGlobals } from './globals'
import { seedMedia } from './media'
import { seedPages } from './pages'
import { seedProjects } from './projects'
import { seedTechnologies } from './technologies'
import { seedUsers } from './users'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const seed = async (_payload: Payload): Promise<void> => {
await seedUsers()
const forms = await seedForms()
const media = await seedMedia()
await seedGlobals(media)
const technologies = await seedTechnologies()
const projects = await seedProjects(media, technologies)
await seedPages(forms, projects)
}

View File

@@ -0,0 +1,81 @@
import payload from 'payload'
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export async function seedMedia() {
const profileImage = await payload.create({
collection: 'media',
data: {
alt: 'Profile picture',
},
filePath: `${__dirname}/media/headshot.png`,
})
const designDesignFeaturedScreenshot = await payload.create({
collection: 'media',
data: {
alt: 'Marketing Image for Pre-Launch',
},
filePath: `${__dirname}/media/design-design-featured.png`,
})
const outsideAppFeaturedScreenshot = await payload.create({
collection: 'media',
data: {
alt: 'Marketing Image for Pre-Launch',
},
filePath: `${__dirname}/media/outside-app-featured.png`,
})
const designAppFeaturedScreenshot = await payload.create({
collection: 'media',
data: {
alt: 'Marketing Image for Pre-Launch',
},
filePath: `${__dirname}/media/design-app-featured.png`,
})
const artAppFeaturedScreenshot = await payload.create({
collection: 'media',
data: {
alt: 'Marketing Image for Pre-Launch',
},
filePath: `${__dirname}/media/art-app-featured.png`,
})
const genericMarketingImageOne = await payload.create({
collection: 'media',
data: {
alt: 'Marketing Image for Pre-Launch',
},
filePath: `${__dirname}/media/generic-1.png`,
})
const genericMarketingImageTwo = await payload.create({
collection: 'media',
data: {
alt: 'Marketing Image for Pre-Launch',
},
filePath: `${__dirname}/media/generic-2.png`,
})
const genericMarketingImageThree = await payload.create({
collection: 'media',
data: {
alt: 'UI/UX Examples',
},
filePath: `${__dirname}/media/generic-3.png`,
})
return {
genericMarketingImageOne,
genericMarketingImageTwo,
genericMarketingImageThree,
profileImage,
designDesignFeaturedScreenshot,
outsideAppFeaturedScreenshot,
designAppFeaturedScreenshot,
artAppFeaturedScreenshot,
}
}
export type InitialMedia = Awaited<ReturnType<typeof seedMedia>>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Some files were not shown because too many files have changed in this diff Show More