chore: scaffolds out developer-portfolio template
9
templates/developer-portfolio/.env.example
Normal 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
|
||||
9
templates/developer-portfolio/.eslintrc.js
Normal 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',
|
||||
},
|
||||
}
|
||||
9
templates/developer-portfolio/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
.next
|
||||
.vercel
|
||||
src/media
|
||||
.DS_Store
|
||||
1
templates/developer-portfolio/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
**/payload-types.ts
|
||||
8
templates/developer-portfolio/.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
76
templates/developer-portfolio/README.md
Normal 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).
|
||||
16
templates/developer-portfolio/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
templates/developer-portfolio/eject.ts
Normal 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()
|
||||
5
templates/developer-portfolio/next-env.d.ts
vendored
Normal 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.
|
||||
18
templates/developer-portfolio/next.config.js
Normal 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,
|
||||
},
|
||||
}
|
||||
5
templates/developer-portfolio/nodemon.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"watch": ["server.ts"],
|
||||
"exec": "ts-node --project tsconfig.server.json src/server.ts",
|
||||
"ext": "js ts"
|
||||
}
|
||||
75
templates/developer-portfolio/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
templates/developer-portfolio/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
templates/developer-portfolio/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
15
templates/developer-portfolio/public/favicon.svg
Normal 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 |
BIN
templates/developer-portfolio/public/outside-app-2.png
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
templates/developer-portfolio/public/outside-app-3.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
templates/developer-portfolio/public/outside-app-4.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
templates/developer-portfolio/public/payload-logo.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
templates/developer-portfolio/public/project-1.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
templates/developer-portfolio/public/project-2.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
templates/developer-portfolio/public/project-3.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
templates/developer-portfolio/public/project-4.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
5
templates/developer-portfolio/src/access/loggedIn.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
export const loggedIn: Access = ({ req: { user } }) => {
|
||||
return Boolean(user)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
54
templates/developer-portfolio/src/app/[slug]/page.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
124
templates/developer-portfolio/src/app/_components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
167
templates/developer-portfolio/src/app/_components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
}
|
||||
82
templates/developer-portfolio/src/app/_utils/api.ts
Normal 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
|
||||
}
|
||||
13
templates/developer-portfolio/src/app/_utils/format.ts
Normal 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
|
||||
}
|
||||
15
templates/developer-portfolio/src/app/_utils/preview.ts
Normal 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 }
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
76
templates/developer-portfolio/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
46
templates/developer-portfolio/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
templates/developer-portfolio/src/app/not-found.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
templates/developer-portfolio/src/app/page.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
53
templates/developer-portfolio/src/blocks/Content/index.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
24
templates/developer-portfolio/src/blocks/Form/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
57
templates/developer-portfolio/src/blocks/Media/index.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.profileHeroHeader {
|
||||
padding: 15px 0;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
22
templates/developer-portfolio/src/blocks/ProfileCTA/index.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
24
templates/developer-portfolio/src/collections/Media.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
68
templates/developer-portfolio/src/collections/Pages/index.ts
Normal 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
|
||||
126
templates/developer-portfolio/src/collections/Projects/index.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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%',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
17
templates/developer-portfolio/src/collections/Users.ts
Normal 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: [],
|
||||
}
|
||||
138
templates/developer-portfolio/src/fields/link.ts
Normal 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
|
||||
27
templates/developer-portfolio/src/globals/Header.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
65
templates/developer-portfolio/src/globals/Profile.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
382
templates/developer-portfolio/src/payload-types.ts
Normal 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;
|
||||
}
|
||||
50
templates/developer-portfolio/src/payload.config.ts
Normal 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'),
|
||||
},
|
||||
})
|
||||
65
templates/developer-portfolio/src/seed/forms.ts
Normal 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>>
|
||||
59
templates/developer-portfolio/src/seed/globals.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
24
templates/developer-portfolio/src/seed/index.ts
Normal 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)
|
||||
}
|
||||
81
templates/developer-portfolio/src/seed/media.ts
Normal 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>>
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
templates/developer-portfolio/src/seed/media/generic-1.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
templates/developer-portfolio/src/seed/media/generic-2.png
Normal file
|
After Width: | Height: | Size: 5.5 MiB |
BIN
templates/developer-portfolio/src/seed/media/generic-3.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
templates/developer-portfolio/src/seed/media/headshot.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |