init commit

This commit is contained in:
Yehoshua Sandler 2025-04-25 14:38:06 -05:00
parent 9e531aabce
commit 6c11574836
54 changed files with 4668 additions and 306 deletions

View File

@ -1,2 +1,3 @@
DATABASE_URI=postgres://postgres:<password>@127.0.0.1:5432/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE
PORT=3000

View File

@ -1,38 +1,11 @@
# Payload Blank Template
This template comes configured with the bare minimum to get started on anything you need.
## Quick start
This template can be deployed directly from our Cloud hosting and it will setup MongoDB and cloud S3 object storage for media.
## Quick Start - local setup
To spin up this template locally, follow these steps:
### Clone
After you click the `Deploy` button above, you'll want to have standalone copy of this repo on your machine. If you've already cloned this repo, skip to [Development](#development).
# Payload Personal Portfolio
### Development
1. First [clone the repo](#clone) if you have not done so already
2. `cd my-project && cp .env.example .env` to copy the example environment variables. You'll need to add the `MONGODB_URI` from your Cloud project to your `.env` if you want to use S3 storage and the MongoDB database that was created for you.
1. `cp .env.example .env` to copy the example environment variables.
2. `npm install && npm run dev` to install dependencies and start the dev server
3. open `http://localhost:{process.env.PORT}` to open the app in your browser
3. `pnpm install && pnpm dev` to install dependencies and start the dev server
4. open `http://localhost:3000` to open the app in your browser
That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live.
#### Docker (Optional)
If you prefer to use Docker for local development instead of a local MongoDB instance, the provided docker-compose.yml file can be used.
To do so, follow these steps:
- Modify the `MONGODB_URI` in your `.env` file to `mongodb://127.0.0.1/<dbname>`
- Modify the `docker-compose.yml` file's `MONGODB_URI` to match the above `<dbname>`
- Run `docker-compose up` to start the database, optionally pass `-d` to run in the background.
## How it works
@ -62,6 +35,4 @@ Alternatively, you can use [Docker](https://www.docker.com) to spin up this temp
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
## 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).

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -1,32 +1,30 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3000:3000'
- "${PORT}:${PORT}"
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
depends_on:
- mongo
# depends_on:
# - mongo
# - postgres
env_file:
- .env
# Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name
mongo:
image: mongo:latest
ports:
- '27017:27017'
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
# mongo:
# image: mongo:latest
# ports:
# - '27017:27017'
# command:
# - --storageEngine=wiredTiger
# volumes:
# - data:/data/db
# logging:
# driver: none
# Uncomment the following to use postgres
# postgres:

View File

@ -15,7 +15,7 @@ const eslintConfig = [
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{

1689
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "ysandler-work",
"version": "1.0.0",
"description": "A blank template to get started with Payload 3.0",
"description": "Personal Portfolio Site",
"license": "MIT",
"type": "module",
"scripts": {
@ -15,26 +15,42 @@
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/db-postgres": "3.33.0",
"@payloadcms/next": "3.33.0",
"@payloadcms/payload-cloud": "3.33.0",
"@payloadcms/richtext-lexical": "3.33.0",
"@radix-ui/react-avatar": "^1.1.7",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.4",
"@tailwindcss/postcss": "^4.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"graphql": "^16.8.1",
"lucide-react": "^0.503.0",
"motion": "^12.7.4",
"next": "15.3.0",
"next-themes": "^0.4.6",
"payload": "3.33.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-markdown": "^10.1.0",
"sharp": "0.32.6",
"@payloadcms/db-postgres": "3.33.0"
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.16.0",
"eslint-config-next": "15.3.0",
"postcss": "^8.5.3",
"prettier": "^3.4.2",
"tailwindcss": "^4.1.4",
"typescript": "5.7.3"
},
"engines": {

6
postcss.config.mjs Normal file
View File

@ -0,0 +1,6 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

View File

@ -0,0 +1,8 @@
'use client'
import React from 'react'
const PageClient: React.FC = () => {
return <React.Fragment />
}
export default PageClient

View File

@ -0,0 +1,87 @@
import configPromise from '@payload-config'
import { getPayload, type RequiredDataFromCollectionSlug } from 'payload'
import { draftMode } from "next/headers";
import { cache } from "react";
import { RenderBlocks } from "@/blocks/RenderBlocks";
import PageClient from "./page.client";
export async function generateStaticParams() {
const payload = await getPayload({ config: configPromise })
const pages = await payload.find({
collection: 'pages',
draft: false,
limit: 1000,
overrideAccess: false,
pagination: false,
select: {
slug: true,
},
})
const params = pages.docs
?.filter((doc) => {
return doc.slug !== 'home'
})
.map(({ slug }) => {
return { slug }
})
return params
}
const queryPageBySlug = cache(async ({ slug }: { slug: string }) => {
const { isEnabled: draft } = await draftMode()
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'pages',
draft,
limit: 1,
pagination: false,
overrideAccess: draft,
where: {
slug: {
equals: slug,
},
},
})
return result.docs?.[0] || null
})
type Args = {
params: Promise<{
slug?: string
}>
}
export default async function Page({ params: paramsPromise }: Args) {
const { slug = 'home' } = await paramsPromise
//const { isEnabled: draft } = await draftMode()
//const url = '/' + slug
const page: RequiredDataFromCollectionSlug<'pages'> | null = await queryPageBySlug({
slug,
}) || null
if (!page) {
return <div /> // return <PayloadRedirects url={url} />
}
const { layout } = page
return (
<article className="flex flex-col min-h-[100dvh] space-y-10" >
<PageClient />
{/* Allows redirects for valid pages too */}
{/*<PayloadRedirects disableNotFound url={url} />*/}
{/*draft && <LivePreviewListener />*/}
<RenderBlocks blocks={layout} />
</article>
)
}

View File

@ -1,18 +1,82 @@
import React from 'react'
import './styles.css'
import { ThemeProvider } from '@/components/theme-provider'
import { TooltipProvider } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { Metadata } from 'next'
import { Inter as FontSans } from 'next/font/google'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import Navbar from '@/globals/Nav/component'
export const metadata = {
description: 'A blank template using Payload in a Next.js app.',
title: 'Payload Blank Template',
const fontSans = FontSans({
subsets: ['latin'],
variable: '--font-sans',
})
const payload = await getPayload({ config: configPromise })
const metaProps = await payload.findGlobal({
slug: 'meta',
})
const navProps = await payload.findGlobal({
slug: 'nav',
})
export const metadata: Metadata = {
metadataBase: new URL(metaProps.url || ''),
title: {
default: metaProps.name || '',
template: `%s | ${metaProps.name}`,
},
description: metaProps.description,
openGraph: {
title: `${metaProps.name}`,
description: metaProps.description || '',
url: metaProps.url || '',
siteName: `${metaProps.name}`,
locale: 'en_US',
type: 'website',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
twitter: {
title: `${metaProps.name}`,
card: 'summary_large_image',
},
verification: {
google: `${metaProps.googleVerification}`,
yandex: '',
},
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>
<main>{children}</main>
<html lang="en" suppressHydrationWarning>
<body
className={cn(
'min-h-screen bg-background font-sans antialiased max-w-2xl mx-auto py-12 sm:py-24 px-6',
fontSans.variable,
)}
>
<ThemeProvider attribute="class" defaultTheme="light">
<TooltipProvider delayDuration={0}>
<main>{children}</main>
<Navbar {...navProps} />
</TooltipProvider>
</ThemeProvider>
</body>
</html>
)

View File

@ -1,59 +1,3 @@
import { headers as getHeaders } from 'next/headers.js'
import Image from 'next/image'
import { getPayload } from 'payload'
import React from 'react'
import { fileURLToPath } from 'url'
import PageTemplate from './[slug]/page'
import config from '@/payload.config'
import './styles.css'
export default async function HomePage() {
const headers = await getHeaders()
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { user } = await payload.auth({ headers })
const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
return (
<div className="home">
<div className="content">
<picture>
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
<Image
alt="Payload Logo"
height={65}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
width={65}
/>
</picture>
{!user && <h1>Welcome to your new project.</h1>}
{user && <h1>Welcome back, {user.email}</h1>}
<div className="links">
<a
className="admin"
href={payloadConfig.routes.admin}
rel="noopener noreferrer"
target="_blank"
>
Go to admin panel
</a>
<a
className="docs"
href="https://payloadcms.com/docs"
rel="noopener noreferrer"
target="_blank"
>
Documentation
</a>
</div>
</div>
<div className="footer">
<p>Update this page by editing</p>
<a className="codeLink" href={fileURL}>
<code>app/(frontend)/page.tsx</code>
</a>
</div>
</div>
)
}
export default PageTemplate

View File

@ -1,164 +1 @@
:root {
--font-mono: 'Roboto Mono', monospace;
}
* {
box-sizing: border-box;
}
html {
font-size: 18px;
line-height: 32px;
background: rgb(0, 0, 0);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: system-ui;
font-size: 18px;
line-height: 32px;
margin: 0;
color: rgb(1000, 1000, 1000);
@media (max-width: 1024px) {
font-size: 15px;
line-height: 24px;
}
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@media (max-width: 1024px) {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
@media (max-width: 768px) {
font-size: 38px;
line-height: 38px;
}
@media (max-width: 400px) {
font-size: 32px;
line-height: 32px;
}
}
p {
margin: 24px 0;
@media (max-width: 1024px) {
margin: calc(var(--base) * 0.75) 0;
}
}
a {
color: currentColor;
&:focus {
opacity: 0.8;
outline: none;
}
&:active {
opacity: 0.7;
outline: none;
}
}
svg {
vertical-align: middle;
}
.home {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: 100vh;
padding: 45px;
max-width: 1024px;
margin: 0 auto;
overflow: hidden;
@media (max-width: 400px) {
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
h1 {
text-align: center;
}
}
.links {
display: flex;
align-items: center;
gap: 12px;
a {
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.admin {
color: rgb(0, 0, 0);
background: rgb(1000, 1000, 1000);
border: 1px solid rgb(0, 0, 0);
}
.docs {
color: rgb(1000, 1000, 1000);
background: rgb(0, 0, 0);
border: 1px solid rgb(1000, 1000, 1000);
}
}
.footer {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 1024px) {
flex-direction: column;
gap: 6px;
}
p {
margin: 0;
}
.codeLink {
text-decoration: none;
padding: 0 0.5rem;
background: rgb(60, 60, 60);
border-radius: 4px;
}
}
}

View File

@ -1 +1,37 @@
export const importMap = {}
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
}

View File

@ -23,9 +23,13 @@ const serverFunction: ServerFunctionClient = async function (args) {
}
const Layout = ({ children }: Args) => (
<html>
<body>
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
</body>
</html>
)
export default Layout

120
src/app/globals.css Normal file
View File

@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

12
src/app/layout.tsx Normal file
View File

@ -0,0 +1,12 @@
import { ReactNode } from 'react'
import './globals.css'
type LayoutProps = {
children: ReactNode
}
const Layout = ({ children }: LayoutProps) => {
return children
}
export default Layout

View File

@ -0,0 +1,33 @@
import { Badge } from '@/components/ui/badge'
import BlurFade from '@/components/ui/blur-fade'
import { BadgeListBlock as BadgeListProps } from '@/payload-types'
import clsx from 'clsx'
const BLUR_FADE_DELAY = 0.04
type Props = { className?: string } & BadgeListProps
export const BadgeList = (props: Props) => {
return (
<section id={props.blockName || ''} className="mt-1">
<div className="flex flex-col gap-y-3">
{!props.shouldHideListHeader && (
<BlurFade delay={BLUR_FADE_DELAY * 9}>
<h2 className="text-xl font-bold">{props.listHeader}</h2>
</BlurFade>
)}
<div className="flex flex-wrap gap-1">
{props.badges?.map((b, id) => (
<BlurFade key={b.id} delay={BLUR_FADE_DELAY * 10 + id * 0.05}>
<Badge
className={clsx(props.textSize ? props.textSize : '')}
variant={props.listVariant || 'default'}
>
{b.value}
</Badge>
</BlurFade>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,72 @@
import { Block } from "payload";
export const BadgeList: Block = {
slug: 'badgeList',
interfaceName: 'BadgeListBlock',
fields: [
{
name: 'listHeader',
type: 'text',
},
{
name: 'shouldHideListHeader',
type: 'checkbox',
},
{
name: 'defaultColor',
type: 'text',
},
{
name: 'defaultTextColor',
type: 'text',
},
{
name: 'textSize',
type: 'text',
admin: {
description: 'A valid CSS text size string',
},
},
{
name: 'listVariant',
type: 'select',
options: [
'default',
'secondary',
'destructive',
'outline',
],
defaultValue: 'default',
},
{
name: 'badges',
type: 'array',
fields: [
{
name: 'value',
type: 'text',
},
{
name: 'color',
type: 'text',
},
{
name: 'textColor',
type: 'text',
admin: {
description: 'String of a valid CSS color',
}
},
{
name: 'icon',
type: 'relationship',
relationTo: 'media'
},
{
name: 'href',
type: 'text',
}
],
},
],
}

View File

@ -0,0 +1,41 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import BlurFade from '@/components/ui/blur-fade'
import BlurFadeText from '@/components/ui/blur-fade-text'
import type { Media, ProfileBriefBlock as ProfileBriefProps } from '@/payload-types'
const BLUR_FADE_DELAY = 0.04
const INITITALS = 'YS'
type Props = {
className?: string
} & ProfileBriefProps
export const ProfileBrief = (props: Props) => {
const avatar = (props.avatar as Media) || undefined
return (
<section id="hero">
<div className="mx-auto w-full max-w-2xl space-y-8">
<div className="gap-2 flex justify-between">
<div className="flex-col flex flex-1 space-y-1.5">
<BlurFadeText
delay={BLUR_FADE_DELAY}
className="text-3xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none"
yOffset={8}
text={props.heading || ''}
/>
<BlurFadeText
className="max-w-[600px] md:text-xl"
delay={BLUR_FADE_DELAY}
text={props.subheading || ''}
/>
</div>
<BlurFade delay={BLUR_FADE_DELAY}>
<Avatar className="size-28 border">
<AvatarImage alt={avatar?.alt || ''} src={avatar?.url || ''} />
<AvatarFallback>{INITITALS}</AvatarFallback>
</Avatar>
</BlurFade>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,21 @@
import { Block } from "payload";
export const ProfileBrief: Block = {
slug: 'profileBrief',
interfaceName: 'ProfileBriefBlock',
fields: [
{
name: 'heading',
type: 'text',
},
{
name: 'subheading',
type: 'text',
},
{
name: 'avatar',
type: 'relationship',
relationTo: 'media'
},
],
}

View File

@ -0,0 +1,46 @@
import React, { Fragment } from 'react'
import type { Page } from '@/payload-types'
import { SimpleBrief } from './SimpleBrief/component'
import { SimpleList } from './SimpleList/component'
import { Showcase } from './Showcase/component'
import { ProfileBrief } from './ProfileBrief/component'
import { BadgeList } from './BadgeList/component'
const blockComponents = {
simpleBrief: SimpleBrief,
simpleList: SimpleList,
showcase: Showcase,
profileBrief: ProfileBrief,
badgeList: BadgeList,
}
export const RenderBlocks: React.FC<{
blocks: Page['layout']
}> = (props) => {
const blocks = Array.from(props.blocks?.values() || [])
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
if (!hasBlocks) return null
return (
<Fragment>
{blocks.map((block, index) => {
const { blockType } = block
if (blockType && blockType in blockComponents) {
const Block = blockComponents[blockType]
if (Block) {
return (
<div key={index}>
{/* @ts-expect-error there may be some mismatch between the expected types here */}
<Block {...block} disableInnerContainer />
</div>
)
}
}
return null
})}
</Fragment>
)
}

View File

@ -0,0 +1,56 @@
import { ProjectCard } from '@/components/project-card'
import BlurFade from '@/components/ui/blur-fade'
import { BadgeListBlock, Media, ShowcaseBlock as ShowcaseProps } from '@/payload-types'
import { ShowcaseCardBlock as ShowcaseCardProps } from '@/payload-types'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
const BLUR_FADE_DELAY = 0.04
type CardProps = { className?: string; index: number } & ShowcaseCardProps
export const ShowcaseCard = (props: CardProps) => {
const realtedImage = props.image as Media | undefined
const realtedVideo = props.video as Media | undefined
const description = props.description as DefaultTypedEditorState | undefined
const tags = (props.tags as BadgeListBlock[]) || []
const links = (props.links as BadgeListBlock[]) || []
return (
<BlurFade delay={BLUR_FADE_DELAY * 12 + props.index * 0.05}>
<ProjectCard
href={props.mainLink || ''}
title={props.title || ''}
description={description}
dates={props.unstructuredDate || ''}
tags={tags}
image={realtedImage}
video={realtedVideo}
links={links}
/>
</BlurFade>
)
}
type ShowcaseComponentProps = { className: string } & ShowcaseProps
export const Showcase = (props: ShowcaseComponentProps) => {
return (
<section id={props.blockName || ''}>
<div className="space-y-12 w-full py-12">
<BlurFade delay={BLUR_FADE_DELAY * 11}>
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<h2 className="inline-block rounded-lg bg-foreground text-background px-3 py-1 text-sm">
{props.listName}
</h2>
<h3 className="text-3xl font-bold tracking-tighter sm:text-5xl">{props.heading}</h3>
<p className="text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
{props.subheading}
</p>
</div>
</div>
</BlurFade>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 max-w-[800px] mx-auto">
{props.lineItems?.map((item, i) => <ShowcaseCard key={item.title} index={i} {...item} />)}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,71 @@
import { Block } from "payload";
import { BadgeList } from "../BadgeList/config";
export const ShowcaseCard: Block = {
slug: 'showcaseCard',
interfaceName: 'ShowcaseCardBlock',
fields: [
{
name: 'image',
type: 'relationship',
relationTo: 'media'
},
{
name: 'video',
type: 'relationship',
relationTo: 'media'
},
{
name: 'mainLink',
type: 'text',
},
{
name: 'title',
type: 'text',
},
{
name: 'unstructuredDate',
type: 'text',
},
{
name: 'description',
type: 'richText'
},
{
name: 'tags',
label: 'Add Tag Section',
type: 'blocks',
blocks: [BadgeList],
},
{
name: 'links',
label: 'Add Link Section',
type: 'blocks',
blocks: [BadgeList],
},
]
}
export const Showcase: Block = {
slug: 'showcase',
interfaceName: 'ShowcaseBlock',
fields: [
{
name: 'listName',
type: 'text',
},
{
name: 'heading',
type: 'text',
},
{
name: 'subheading',
type: 'text',
},
{
name: 'lineItems',
type: 'blocks',
blocks: [ShowcaseCard]
},
],
}

View File

@ -0,0 +1,23 @@
import { RichText } from '@/components/RichText'
import BlurFade from '@/components/ui/blur-fade'
import { SimpleBriefBlock } from '@/payload-types'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
const BLUR_FADE_DELAY = 0.04
type Props = { className: string } & SimpleBriefBlock
export const SimpleBrief = (props: Props) => {
return (
<section id={props.blockName || ''}>
<BlurFade delay={BLUR_FADE_DELAY * 3}>
<h2 className="text-xl font-bold">{props.title}</h2>
</BlurFade>
<BlurFade delay={BLUR_FADE_DELAY * 4}>
<RichText
data={props.content as DefaultTypedEditorState}
className="prose max-w-full text-pretty font-sans text-sm text-muted-foreground dark:prose-invert"
/>
</BlurFade>
</section>
)
}

View File

@ -0,0 +1,16 @@
import { Block } from "payload";
export const SimpleBrief: Block = {
slug: 'simpleBrief',
interfaceName: 'simpleBriefBlock',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'richText'
}
],
}

View File

@ -0,0 +1,39 @@
import { ResumeCard } from '@/components/resume-card'
import BlurFade from '@/components/ui/blur-fade'
import { Media, SimpleListBlock } from '@/payload-types'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
const BLUR_FADE_DELAY = 0.04
type Props = {
className: string
} & SimpleListBlock
export const SimpleList = (props: Props) => {
return (
<section id="work">
<div className="flex min-h-0 flex-col gap-y-3">
<BlurFade delay={BLUR_FADE_DELAY * 5}>
<h2 className="text-xl font-bold">{props.listHeader}</h2>
</BlurFade>
{props.lineItems?.map((item, id) => {
const avatar = item.avatar as Media | undefined
return (
<BlurFade key={item.title || '' + id} delay={BLUR_FADE_DELAY * 6 + id * 0.05}>
<ResumeCard
logoUrl={avatar?.url || ''}
altText={avatar?.alt || ''}
title={item.title || ''}
subtitle={item.subtitle || ''}
href={item.link || ''}
badges={[]}
period={item.extraDetail || ''}
description={item.description as DefaultTypedEditorState}
/>
</BlurFade>
)
})}
</div>
</section>
)
}

View File

@ -0,0 +1,51 @@
import { Block } from "payload";
export const SimpleList: Block = {
slug: 'simpleList',
interfaceName: 'SimpleListBlock',
fields: [
{
name: 'listHeader',
type: 'text',
},
{
name: 'lineItems',
type: 'array',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'subtitle',
type: 'text',
},
{
name: 'description',
type: 'richText',
},
{
name: 'extraDetail',
type: 'text',
},
{
name: 'avatar',
type: 'relationship',
relationTo: 'media',
},
{
name: 'initials',
type: 'text',
},
{
name: 'link',
type: 'text',
},
{
name: 'isInitiallyHidden',
type: 'checkbox',
},
],
},
],
}

27
src/collections/Pages.ts Normal file
View File

@ -0,0 +1,27 @@
import { BadgeList } from "@/blocks/BadgeList/config";
import { ProfileBrief } from "@/blocks/ProfileBrief/config";
import { Showcase } from "@/blocks/Showcase/config";
import { SimpleBrief } from "@/blocks/SimpleBrief/config";
import { SimpleList } from "@/blocks/SimpleList/config";
import { CollectionConfig } from "payload";
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'slug',
},
access: {
read: () => true
},
fields: [
{
name: 'slug',
type: 'text',
},
{
name: 'layout',
type: 'blocks',
blocks: [SimpleList, Showcase, ProfileBrief, SimpleBrief, BadgeList],
},
]
}

View File

@ -0,0 +1,60 @@
import { cn } from '@/lib/utils'
import {
DefaultNodeTypes,
SerializedBlockNode,
SerializedLinkNode,
type DefaultTypedEditorState,
} from '@payloadcms/richtext-lexical'
import {
JSXConvertersFunction,
LinkJSXConverter,
RichText as ConvertRichText,
} from '@payloadcms/richtext-lexical/react'
import { BadgeList } from '@/blocks/BadgeList/component'
import { BadgeListBlock } from '@/payload-types'
type NodeTypes = DefaultNodeTypes | SerializedBlockNode<BadgeListBlock>
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
const { value, relationTo } = linkNode.fields.doc!
if (typeof value !== 'object') {
throw new Error('Expected value to be an object')
}
const slug = value.slug
return relationTo === 'posts' ? `/posts/${slug}` : `/${slug}`
}
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => {
return {
...defaultConverters,
...LinkJSXConverter({ internalDocToHref }),
blocks: {
badgeList: ({ node }) => <BadgeList {...node.fields} />,
},
}
}
type Props = {
data: DefaultTypedEditorState
enableGutter?: boolean
enableProse?: boolean
} & React.HTMLAttributes<HTMLDivElement>
export const RichText = (props: Props) => {
const { className, enableProse = true, enableGutter = true, ...rest } = props
return (
<ConvertRichText
converters={jsxConverters}
className={cn(
'payload-richtext',
{
container: enableGutter,
'max-w-none': !enableGutter,
'mx-auto prose md:prose-md dark:prose-invert': enableProse,
},
className,
)}
{...rest}
/>
)
}

View File

@ -0,0 +1,62 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
interface Props {
title: string;
description: string;
dates: string;
location: string;
image?: string;
links?: readonly {
icon: React.ReactNode;
title: string;
href: string;
}[];
}
export function HackathonCard({
title,
description,
dates,
location,
image,
links,
}: Props) {
return (
<li className="relative ml-10 py-4">
<div className="absolute -left-16 top-2 flex items-center justify-center bg-white rounded-full">
<Avatar className="border size-12 m-auto">
<AvatarImage src={image} alt={title} className="object-contain" />
<AvatarFallback>{title[0]}</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-1 flex-col justify-start gap-1">
{dates && (
<time className="text-xs text-muted-foreground">{dates}</time>
)}
<h2 className="font-semibold leading-none">{title}</h2>
{location && (
<p className="text-sm text-muted-foreground">{location}</p>
)}
{description && (
<span className="prose dark:prose-invert text-sm text-muted-foreground">
{description}
</span>
)}
</div>
{links && links.length > 0 && (
<div className="mt-2 flex flex-row flex-wrap items-start gap-2">
{links?.map((link, idx) => (
<Link href={link.href} key={idx}>
<Badge key={idx} title={link.title} className="flex gap-2">
{link.icon}
{link.title}
</Badge>
</Link>
))}
</div>
)}
</li>
);
}

223
src/components/icons.tsx Normal file
View File

@ -0,0 +1,223 @@
import { GlobeIcon, MailIcon } from "lucide-react";
export type IconProps = React.HTMLAttributes<SVGElement>;
export const Icons = {
globe: (props: IconProps) => <GlobeIcon {...props} />,
email: (props: IconProps) => <MailIcon {...props} />,
linkedin: (props: IconProps) => (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<title>LinkedIn</title>
<path
fill="currentColor"
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
</svg>
),
x: (props: IconProps) => (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<title>X</title>
<path
fill="currentColor"
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/>
</svg>
),
youtube: (props: IconProps) => (
<svg
width="32px"
height="32px"
viewBox="0 0 32 32"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>youtube</title>
<path d="M29.41,9.26a3.5,3.5,0,0,0-2.47-2.47C24.76,6.2,16,6.2,16,6.2s-8.76,0-10.94.59A3.5,3.5,0,0,0,2.59,9.26,36.13,36.13,0,0,0,2,16a36.13,36.13,0,0,0,.59,6.74,3.5,3.5,0,0,0,2.47,2.47C7.24,25.8,16,25.8,16,25.8s8.76,0,10.94-.59a3.5,3.5,0,0,0,2.47-2.47A36.13,36.13,0,0,0,30,16,36.13,36.13,0,0,0,29.41,9.26ZM13.2,20.2V11.8L20.47,16Z" />
</svg>
),
nextjs: (props: IconProps) => (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="size-8"
fill="currentColor"
{...props}
>
<title>Next.js</title>
<path d="M11.5725 0c-.1763 0-.3098.0013-.3584.0067-.0516.0053-.2159.021-.3636.0328-3.4088.3073-6.6017 2.1463-8.624 4.9728C1.1004 6.584.3802 8.3666.1082 10.255c-.0962.659-.108.8537-.108 1.7474s.012 1.0884.108 1.7476c.652 4.506 3.8591 8.2919 8.2087 9.6945.7789.2511 1.6.4223 2.5337.5255.3636.04 1.9354.04 2.299 0 1.6117-.1783 2.9772-.577 4.3237-1.2643.2065-.1056.2464-.1337.2183-.1573-.0188-.0139-.8987-1.1938-1.9543-2.62l-1.919-2.592-2.4047-3.5583c-1.3231-1.9564-2.4117-3.556-2.4211-3.556-.0094-.0026-.0187 1.5787-.0235 3.509-.0067 3.3802-.0093 3.5162-.0516 3.596-.061.115-.108.1618-.2064.2134-.075.0374-.1408.0445-.495.0445h-.406l-.1078-.068a.4383.4383 0 01-.1572-.1712l-.0493-.1056.0053-4.703.0067-4.7054.0726-.0915c.0376-.0493.1174-.1125.1736-.143.0962-.047.1338-.0517.5396-.0517.4787 0 .5584.0187.6827.1547.0353.0377 1.3373 1.9987 2.895 4.3608a10760.433 10760.433 0 004.7344 7.1706l1.9002 2.8782.096-.0633c.8518-.5536 1.7525-1.3418 2.4657-2.1627 1.5179-1.7429 2.4963-3.868 2.8247-6.134.0961-.6591.1078-.854.1078-1.7475 0-.8937-.012-1.0884-.1078-1.7476-.6522-4.506-3.8592-8.2919-8.2087-9.6945-.7672-.2487-1.5836-.42-2.4985-.5232-.169-.0176-1.0835-.0366-1.6123-.037zm4.0685 7.217c.3473 0 .4082.0053.4857.047.1127.0562.204.1642.237.2767.0186.061.0234 1.3653.0186 4.3044l-.0067 4.2175-.7436-1.14-.7461-1.14v-3.066c0-1.982.0093-3.0963.0234-3.1502.0375-.1313.1196-.2346.2323-.2955.0961-.0494.1313-.054.4997-.054z" />
</svg>
),
framermotion: (props: IconProps) => (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="size-8"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<title>Framer Motion</title>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12l-8 -8v16l16 -16v16l-4 -4" />
<path d="M20 12l-8 8l-4 -4" />
</svg>
),
tailwindcss: (props: IconProps) => (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="size-8"
fill="currentColor"
{...props}
>
<title>Tailwind CSS</title>
<path d="m12.001 4.8c-3.2 0-5.2 1.6-6 4.8 1.2-1.6 2.6-2.2 4.2-1.8.913.228 1.565.89 2.288 1.624 1.177 1.194 2.538 2.576 5.512 2.576 3.2 0 5.2-1.6 6-4.8-1.2 1.6-2.6 2.2-4.2 1.8-.913-.228-1.565-.89-2.288-1.624-1.176-1.194-2.537-2.576-5.512-2.576zm-6 7.2c-3.2 0-5.2 1.6-6 4.8 1.2-1.6 2.6-2.2 4.2-1.8.913.228 1.565.89 2.288 1.624 1.177 1.194 2.538 2.576 5.512 2.576 3.2 0 5.2-1.6 6-4.8-1.2 1.6-2.6 2.2-4.2 1.8-.913-.228-1.565-.89-2.288-1.624-1.176-1.194-2.537-2.576-5.512-2.576z" />
</svg>
),
typescript: (props: IconProps) => (
<svg
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
className="size-8"
fill="currentColor"
{...props}
>
<path d="m0 16v16h32v-32h-32zm25.786-1.276c.813.203 1.432.568 2.005 1.156.292.312.729.885.766 1.026.01.042-1.38.974-2.224 1.495-.031.021-.156-.109-.292-.313-.411-.599-.844-.859-1.505-.906-.969-.063-1.594.443-1.589 1.292-.005.208.042.417.135.599.214.443.615.708 1.854 1.245 2.292.984 3.271 1.635 3.88 2.557.682 1.031.833 2.677.375 3.906-.51 1.328-1.771 2.234-3.542 2.531-.547.099-1.849.083-2.438-.026-1.286-.229-2.505-.865-3.255-1.698-.297-.323-.87-1.172-.833-1.229.016-.021.146-.104.292-.188s.682-.396 1.188-.688l.922-.536.193.286c.271.411.859.974 1.214 1.161 1.021.542 2.422.464 3.115-.156.281-.234.438-.594.417-.958 0-.37-.047-.536-.24-.813-.25-.354-.755-.656-2.198-1.281-1.651-.714-2.365-1.151-3.01-1.854-.406-.464-.708-1.01-.88-1.599-.12-.453-.151-1.589-.057-2.042.339-1.599 1.547-2.708 3.281-3.036.563-.109 1.875-.068 2.427.068zm-7.51 1.339.01 1.307h-4.167v11.839h-2.948v-11.839h-4.161v-1.281c0-.714.016-1.307.036-1.323.016-.021 2.547-.031 5.62-.026l5.594.016z" />
</svg>
),
react: (props: IconProps) => (
<svg
role="img"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
className="size-8"
fill="currentColor"
{...props}
>
<title>React</title>
<path d="m16 13.146c-1.573 0-2.854 1.281-2.854 2.854s1.281 2.854 2.854 2.854 2.854-1.281 2.854-2.854-1.281-2.854-2.854-2.854zm-7.99 8.526-.63-.156c-4.688-1.188-7.38-3.198-7.38-5.521s2.693-4.333 7.38-5.521l.63-.156.177.625c.474 1.635 1.083 3.229 1.818 4.771l.135.281-.135.286c-.734 1.536-1.344 3.13-1.818 4.771zm-.921-9.74c-3.563 1-5.75 2.536-5.75 4.063s2.188 3.057 5.75 4.063c.438-1.391.964-2.745 1.578-4.063-.615-1.318-1.141-2.672-1.578-4.063zm16.901 9.74-.177-.625c-.474-1.635-1.083-3.229-1.818-4.766l-.135-.286.135-.286c.734-1.536 1.344-3.13 1.818-4.771l.177-.62.63.156c4.688 1.188 7.38 3.198 7.38 5.521s-2.693 4.333-7.38 5.521zm-.657-5.677c.641 1.385 1.172 2.745 1.578 4.063 3.568-1.005 5.75-2.536 5.75-4.063s-2.188-3.057-5.75-4.063c-.438 1.385-.964 2.745-1.578 4.063zm-16.255-4.068-.177-.625c-1.318-4.646-.917-7.979 1.099-9.141 1.979-1.141 5.151.208 8.479 3.625l.453.464-.453.464c-1.182 1.229-2.26 2.552-3.229 3.958l-.182.255-.313.026c-1.703.135-3.391.406-5.047.813zm2.531-8.838c-.359 0-.677.073-.943.229-1.323.766-1.557 3.422-.646 7.005 1.422-.318 2.859-.542 4.313-.672.833-1.188 1.75-2.323 2.734-3.391-2.078-2.026-4.047-3.172-5.458-3.172zm12.787 27.145c-.005 0-.005 0 0 0-1.901 0-4.344-1.427-6.875-4.031l-.453-.464.453-.464c1.182-1.229 2.26-2.552 3.229-3.958l.177-.255.313-.031c1.703-.13 3.391-.401 5.052-.813l.63-.156.177.625c1.318 4.646.917 7.974-1.099 9.135-.49.281-1.042.422-1.604.411zm-5.464-4.505c2.078 2.026 4.047 3.172 5.458 3.172h.005c.354 0 .672-.078.938-.229 1.323-.766 1.563-3.422.646-7.005-1.422.318-2.865.542-4.313.667-.833 1.193-1.75 2.323-2.734 3.396zm7.99-13.802-.63-.161c-1.661-.406-3.349-.677-5.052-.813l-.313-.026-.177-.255c-.969-1.406-2.047-2.729-3.229-3.958l-.453-.464.453-.464c3.328-3.417 6.5-4.766 8.479-3.625 2.016 1.161 2.417 4.495 1.099 9.141zm-5.255-2.276c1.521.141 2.969.365 4.313.672.917-3.583.677-6.24-.646-7.005-1.318-.76-3.797.406-6.401 2.943.984 1.073 1.896 2.203 2.734 3.391zm-10.058 20.583c-.563.01-1.12-.13-1.609-.411-2.016-1.161-2.417-4.49-1.099-9.135l.177-.625.63.156c1.542.391 3.24.661 5.047.813l.313.031.177.255c.969 1.406 2.047 2.729 3.229 3.958l.453.464-.453.464c-2.526 2.604-4.969 4.031-6.865 4.031zm-1.588-8.567c-.917 3.583-.677 6.24.646 7.005 1.318.75 3.792-.406 6.401-2.943-.984-1.073-1.901-2.203-2.734-3.396-1.453-.125-2.891-.349-4.313-.667zm7.979.838c-1.099 0-2.224-.047-3.354-.141l-.313-.026-.182-.26c-.635-.917-1.24-1.859-1.797-2.828-.563-.969-1.078-1.958-1.557-2.969l-.135-.286.135-.286c.479-1.01.995-2 1.557-2.969.552-.953 1.156-1.906 1.797-2.828l.182-.26.313-.026c2.234-.188 4.479-.188 6.708 0l.313.026.182.26c1.276 1.833 2.401 3.776 3.354 5.797l.135.286-.135.286c-.953 2.021-2.073 3.964-3.354 5.797l-.182.26-.313.026c-1.125.094-2.255.141-3.354.141zm-2.927-1.448c1.969.151 3.885.151 5.859 0 1.099-1.609 2.078-3.302 2.927-5.063-.844-1.76-1.823-3.453-2.932-5.063-1.948-.151-3.906-.151-5.854 0-1.109 1.609-2.089 3.302-2.932 5.063.849 1.76 1.828 3.453 2.932 5.063z" />
</svg>
),
github: (props: IconProps) => (
<svg viewBox="0 0 438.549 438.549" {...props}>
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
></path>
</svg>
),
notion: (props: IconProps) => (
<svg
width="100"
height="100"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z"
fill="#fff"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z"
fill="#000"
/>
</svg>
),
openai: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
),
googleDrive: (props: IconProps) => (
<svg viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
fill="#0066da"
/>
<path
d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z"
fill="#00ac47"
/>
<path
d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
fill="#ea4335"
/>
<path
d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z"
fill="#00832d"
/>
<path
d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
fill="#2684fc"
/>
<path
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
fill="#ffba00"
/>
</svg>
),
whatsapp: (props: IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 175.216 175.552"
{...props}
>
<defs>
<linearGradient
id="b"
x1="85.915"
x2="86.535"
y1="32.567"
y2="137.092"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#57d163" />
<stop offset="1" stopColor="#23b33a" />
</linearGradient>
<filter
id="a"
width="1.115"
height="1.114"
x="-.057"
y="-.057"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation="3.531" />
</filter>
</defs>
<path
fill="#b3b3b3"
d="m54.532 138.45 2.235 1.324c9.387 5.571 20.15 8.518 31.126 8.523h.023c33.707 0 61.139-27.426 61.153-61.135.006-16.335-6.349-31.696-17.895-43.251A60.75 60.75 0 0 0 87.94 25.983c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 9.349 32.535l1.455 2.312-6.179 22.558zm-40.811 23.544L24.16 123.88c-6.438-11.154-9.825-23.808-9.821-36.772.017-40.556 33.021-73.55 73.578-73.55 19.681.01 38.154 7.669 52.047 21.572s21.537 32.383 21.53 52.037c-.018 40.553-33.027 73.553-73.578 73.553h-.032c-12.313-.005-24.412-3.094-35.159-8.954zm0 0"
filter="url(#a)"
/>
<path
fill="#fff"
d="m12.966 161.238 10.439-38.114a73.42 73.42 0 0 1-9.821-36.772c.017-40.556 33.021-73.55 73.578-73.55 19.681.01 38.154 7.669 52.047 21.572s21.537 32.383 21.53 52.037c-.018 40.553-33.027 73.553-73.578 73.553h-.032c-12.313-.005-24.412-3.094-35.159-8.954z"
/>
<path
fill="url(#linearGradient1780)"
d="M87.184 25.227c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 9.349 32.535l1.455 2.312-6.179 22.559 23.146-6.069 2.235 1.324c9.387 5.571 20.15 8.518 31.126 8.524h.023c33.707 0 61.14-27.426 61.153-61.135a60.75 60.75 0 0 0-17.895-43.251 60.75 60.75 0 0 0-43.235-17.929z"
/>
<path
fill="url(#b)"
d="M87.184 25.227c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 9.349 32.535l1.455 2.313-6.179 22.558 23.146-6.069 2.235 1.324c9.387 5.571 20.15 8.517 31.126 8.523h.023c33.707 0 61.14-27.426 61.153-61.135a60.75 60.75 0 0 0-17.895-43.251 60.75 60.75 0 0 0-43.235-17.928z"
/>
<path
fill="#fff"
fillRule="evenodd"
d="M68.772 55.603c-1.378-3.061-2.828-3.123-4.137-3.176l-3.524-.043c-1.226 0-3.218.46-4.902 2.3s-6.435 6.287-6.435 15.332 6.588 17.785 7.506 19.013 12.718 20.381 31.405 27.75c15.529 6.124 18.689 4.906 22.061 4.6s10.877-4.447 12.408-8.74 1.532-7.971 1.073-8.74-1.685-1.226-3.525-2.146-10.877-5.367-12.562-5.981-2.91-.919-4.137.921-4.746 5.979-5.819 7.206-2.144 1.381-3.984.462-7.76-2.861-14.784-9.124c-5.465-4.873-9.154-10.891-10.228-12.73s-.114-2.835.808-3.751c.825-.824 1.838-2.147 2.759-3.22s1.224-1.84 1.836-3.065.307-2.301-.153-3.22-4.032-10.011-5.666-13.647"
/>
</svg>
),
};

View File

@ -0,0 +1,22 @@
"use client";
import { Button } from "@/components/ui/button";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
import { useTheme } from "next-themes";
export function ModeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
type="button"
size="icon"
className="px-2"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<SunIcon className="h-[1.2rem] w-[1.2rem] text-neutral-800 dark:hidden dark:text-neutral-200" />
<MoonIcon className="hidden h-[1.2rem] w-[1.2rem] text-neutral-800 dark:block dark:text-neutral-200" />
</Button>
);
}

View File

@ -0,0 +1,123 @@
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { BadgeListBlock, Media } from '@/payload-types'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import Image from 'next/image'
import Link from 'next/link'
import { RichText } from './RichText'
import clsx from 'clsx'
type Props = {
title: string
href?: string
description?: DefaultTypedEditorState
dates: string
tags?: BadgeListBlock[]
link?: string
image?: Media
video?: Media
links?: BadgeListBlock[]
className?: string
}
export function ProjectCard({
title,
href,
description,
dates,
link,
image,
video,
className,
...rest
}: Props) {
const tags = rest?.tags?.length ? rest.tags[0] : null
const links = rest?.links?.length ? rest.links[0] : null
return (
<Card
className={
'flex flex-col overflow-hidden border hover:shadow-lg transition-all duration-300 ease-out h-full'
}
>
<Link href={href || '#'} className={cn('block cursor-pointer', className)}>
{video && (
<video
src={video.url || ''}
autoPlay
loop
muted
playsInline
className="pointer-events-none mx-auto h-40 w-full object-cover object-top" // needed because random black line at bottom of video
/>
)}
{image && (
<Image
src={image.url || ''}
alt={image.alt || ''}
width={500}
height={300}
className="h-40 w-full overflow-hidden object-cover object-top"
/>
)}
</Link>
<CardHeader className="px-2">
<div className="space-y-1">
<CardTitle className="mt-1 text-base">{title}</CardTitle>
<time className="font-sans text-xs">{dates}</time>
<div className="hidden font-sans text-xs underline print:visible">
{link?.replace('https://', '').replace('www.', '').replace('/', '')}
</div>
{!!description && (
<RichText
className="prose max-w-full text-pretty font-sans text-xs text-muted-foreground dark:prose-invert"
data={description}
/>
)}
</div>
</CardHeader>
<CardContent className="mt-auto flex flex-col px-2">
{tags?.badges && tags.badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.badges.map((b, i) => (
<Badge
key={b.value || '' + i}
variant="secondary"
className={clsx(
'px-1 py-0 text-[10px]',
b.color || tags.defaultColor ? `bg-[${b.color || tags.defaultColor}]` : '',
b.textColor || tags.defaultTextColor
? `text-[${b.textColor || tags.defaultTextColor}]`
: '',
)}
>
{b.value}
</Badge>
))}
</div>
)}
</CardContent>
<CardFooter className="px-2 pb-2">
{links?.badges && links.badges.length > 0 && (
<div className="flex flex-row flex-wrap items-start gap-1">
{links.badges?.map((link, idx) => {
const icon = link.icon as Media | undefined
return (
<Link href={link?.href || ''} key={idx} target="_blank">
<Badge key={idx} className="flex gap-2 px-2 py-1 text-[10px]">
{!!icon && icon.url && (
<Image src={icon.url} alt={icon.alt || ''} width={16} height={16} />
)}
{link.value}
</Badge>
</Link>
)
})}
</div>
)}
</CardFooter>
</Card>
)
}

View File

@ -0,0 +1,112 @@
'use client'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import { motion } from 'framer-motion'
import { ChevronRightIcon } from 'lucide-react'
import Link from 'next/link'
import React from 'react'
import { RichText } from './RichText'
type ResumeCardProps = {
logoUrl: string
altText: string
title: string
subtitle?: string
href?: string
badges?: string[]
period: string
description?: DefaultTypedEditorState
}
export const ResumeCard = ({
logoUrl,
altText,
title,
subtitle,
href,
badges,
period,
description,
}: ResumeCardProps) => {
const [isExpanded, setIsExpanded] = React.useState(false)
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (description) {
e.preventDefault()
setIsExpanded(!isExpanded)
}
}
const handleDoubleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
if (description) setIsExpanded(true)
if (href && window) window.open(href, '_blank')
}
return (
<Link
href={href || '#'}
className="block cursor-pointer"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<Card className="flex">
<div className="flex-none">
<Avatar className="border size-12 m-auto bg-muted-background dark:bg-foreground">
<AvatarImage src={logoUrl} alt={altText} className="object-contain" />
<AvatarFallback>{altText[0]}</AvatarFallback>
</Avatar>
</div>
<div className="flex-grow ml-4 items-center flex-col group">
<CardHeader>
<div className="flex items-center justify-between gap-x-2 text-base">
<h3 className="inline-flex items-center justify-center font-semibold leading-none text-xs sm:text-sm">
{title}
{badges && (
<span className="inline-flex gap-x-1">
{badges.map((badge, index) => (
<Badge variant="secondary" className="align-middle text-xs" key={index}>
{badge}
</Badge>
))}
</span>
)}
<ChevronRightIcon
className={cn(
'size-4 translate-x-0 transform opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100',
isExpanded ? 'rotate-90' : 'rotate-0',
)}
/>
</h3>
<div className="text-xs sm:text-sm tabular-nums text-muted-foreground text-right">
{period}
</div>
</div>
{subtitle && <div className="font-sans text-xs">{subtitle}</div>}
</CardHeader>
{description && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isExpanded ? 1 : 0,
height: isExpanded ? 'auto' : 0,
}}
transition={{
duration: 0.7,
ease: [0.16, 1, 0.3, 1],
}}
className="mt-2 text-xs sm:text-sm"
>
<RichText data={description} />
</motion.div>
)}
</div>
</Card>
</Link>
)
}

View File

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

View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,85 @@
"use client";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, Variants } from "framer-motion";
import { useMemo } from "react";
interface BlurFadeTextProps {
text: string;
className?: string;
variant?: {
hidden: { y: number };
visible: { y: number };
};
duration?: number;
characterDelay?: number;
delay?: number;
yOffset?: number;
animateByCharacter?: boolean;
}
const BlurFadeText = ({
text,
className,
variant,
characterDelay = 0.03,
delay = 0,
yOffset = 8,
animateByCharacter = false,
}: BlurFadeTextProps) => {
const defaultVariants: Variants = {
hidden: { y: yOffset, opacity: 0, filter: "blur(8px)" },
visible: { y: -yOffset, opacity: 1, filter: "blur(0px)" },
};
const combinedVariants = variant || defaultVariants;
const characters = useMemo(() => Array.from(text), [text]);
if (animateByCharacter) {
return (
<div className="flex">
<AnimatePresence>
{characters.map((char, i) => (
<motion.span
key={i}
initial="hidden"
animate="visible"
exit="hidden"
variants={combinedVariants}
transition={{
yoyo: Infinity,
delay: delay + i * characterDelay,
ease: "easeOut",
}}
className={cn("inline-block", className)}
style={{ width: char.trim() === "" ? "0.2em" : "auto" }}
>
{char}
</motion.span>
))}
</AnimatePresence>
</div>
);
}
return (
<div className="flex">
<AnimatePresence>
<motion.span
initial="hidden"
animate="visible"
exit="hidden"
variants={combinedVariants}
transition={{
yoyo: Infinity,
delay,
ease: "easeOut",
}}
className={cn("inline-block", className)}
>
{text}
</motion.span>
</AnimatePresence>
</div>
);
};
export default BlurFadeText;

View File

@ -0,0 +1,61 @@
'use client'
import { AnimatePresence, motion, useInView, Variants } from 'framer-motion'
import { useRef } from 'react'
interface BlurFadeProps {
children: React.ReactNode
className?: string
variant?: {
hidden: { y: number }
visible: { y: number }
}
duration?: number
delay?: number
yOffset?: number
inView?: boolean
inViewMargin?: string
blur?: string
}
const BlurFade = ({
children,
className,
variant,
duration = 0.4,
delay = 0,
yOffset = 6,
inView = false,
inViewMargin = '-50px',
blur = '6px',
}: BlurFadeProps) => {
const ref = useRef(null)
// @ts-ignore
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
const isInView = !inView || inViewResult
const defaultVariants: Variants = {
hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` },
visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` },
}
const combinedVariants = variant || defaultVariants
return (
<AnimatePresence>
<motion.div
ref={ref}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
exit="hidden"
variants={combinedVariants}
transition={{
delay: 0.04 + delay,
duration,
ease: 'easeOut',
}}
className={className}
>
{children}
</motion.div>
</AnimatePresence>
)
}
export default BlurFade

View File

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

View File

@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg bg-card text-card-foreground", className)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-pretty font-sans text-sm text-muted-foreground",
className
)}
{...props}
/>
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center pt-2", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

112
src/components/ui/dock.tsx Normal file
View File

@ -0,0 +1,112 @@
'use client'
import { cn } from '@/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
import React, { PropsWithChildren, useRef } from 'react'
export interface DockProps extends VariantProps<typeof dockVariants> {
className?: string
magnification?: number
distance?: number
children: React.ReactNode
}
const DEFAULT_MAGNIFICATION = 60
const DEFAULT_DISTANCE = 140
const dockVariants = cva('mx-auto w-max h-full p-2 flex items-end rounded-full border')
const Dock = React.forwardRef<HTMLDivElement, DockProps>(
(
{
className,
children,
magnification = DEFAULT_MAGNIFICATION,
distance = DEFAULT_DISTANCE,
...props
},
ref,
) => {
const mousex = useMotionValue(Infinity)
const renderChildren = () => {
return React.Children.map(children, (child: React.ReactNode) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
mousex,
magnification,
distance,
} as DockIconProps)
}
return child
})
}
return (
<motion.div
ref={ref}
onMouseMove={(e) => mousex.set(e.pageX)}
onMouseLeave={() => mousex.set(Infinity)}
{...props}
className={cn(dockVariants({ className }))}
>
{renderChildren()}
</motion.div>
)
},
)
Dock.displayName = 'Dock'
export interface DockIconProps {
size?: number
magnification?: number
distance?: number
mousex?: any
className?: string
children?: React.ReactNode
props?: PropsWithChildren
}
const DockIcon = ({
magnification = DEFAULT_MAGNIFICATION,
distance = DEFAULT_DISTANCE,
mousex,
className,
children,
...props
}: DockIconProps) => {
const ref = useRef<HTMLDivElement>(null)
const distanceCalc = useTransform(mousex, (val: number) => {
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }
return val - bounds.x - bounds.width / 2
})
const widthSync = useTransform(distanceCalc, [-distance, 0, distance], [40, magnification, 40])
const width = useSpring(widthSync, {
mass: 0.1,
stiffness: 150,
damping: 12,
})
return (
<motion.div
ref={ref}
style={{ width }}
className={cn(
'flex aspect-square cursor-pointer items-center justify-center rounded-full',
className,
)}
{...props}
>
{children}
</motion.div>
)
}
DockIcon.displayName = 'DockIcon'
export { Dock, DockIcon, dockVariants }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,119 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, MotionProps } from "motion/react";
import { useEffect, useRef, useState } from "react";
interface AnimatedSpanProps extends MotionProps {
children: React.ReactNode;
delay?: number;
className?: string;
}
export const AnimatedSpan = ({
children,
delay = 0,
className,
...props
}: AnimatedSpanProps) => (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: delay / 1000 }}
className={cn("grid text-sm font-normal tracking-tight", className)}
{...props}
>
{children}
</motion.div>
);
interface TypingAnimationProps extends MotionProps {
children: string;
className?: string;
duration?: number;
delay?: number;
as?: React.ElementType;
}
export const TypingAnimation = ({
children,
className,
duration = 60,
delay = 0,
as: Component = "span",
...props
}: TypingAnimationProps) => {
if (typeof children !== "string") {
throw new Error("TypingAnimation: children must be a string. Received:");
}
const MotionComponent = motion.create(Component, {
forwardMotionProps: true,
});
const [displayedText, setDisplayedText] = useState<string>("");
const [started, setStarted] = useState(false);
const elementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const startTimeout = setTimeout(() => {
setStarted(true);
}, delay);
return () => clearTimeout(startTimeout);
}, [delay]);
useEffect(() => {
if (!started) return;
let i = 0;
const typingEffect = setInterval(() => {
if (i < children.length) {
setDisplayedText(children.substring(0, i + 1));
i++;
} else {
clearInterval(typingEffect);
}
}, duration);
return () => {
clearInterval(typingEffect);
};
}, [children, duration, started]);
return (
<MotionComponent
ref={elementRef}
className={cn("text-sm font-normal tracking-tight", className)}
{...props}
>
{displayedText}
</MotionComponent>
);
};
interface TerminalProps {
children: React.ReactNode;
className?: string;
}
export const Terminal = ({ children, className }: TerminalProps) => {
return (
<div
className={cn(
"z-0 h-full max-h-[400px] w-full max-w-lg rounded-xl border border-border bg-background",
className,
)}
>
<div className="flex flex-col gap-y-2 border-b border-border p-4">
<div className="flex flex-row gap-x-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
<div className="h-2 w-2 rounded-full bg-green-500"></div>
</div>
</div>
<pre className="p-4">
<code className="grid gap-y-1 overflow-auto">{children}</code>
</pre>
</div>
);
};

View File

@ -0,0 +1,30 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@ -0,0 +1,68 @@
import type { TextFieldSingleValidation } from 'payload'
import {
BoldFeature,
ItalicFeature,
LinkFeature,
ParagraphFeature,
lexicalEditor,
UnderlineFeature,
type LinkFields,
UnorderedListFeature,
AlignFeature,
BlockquoteFeature,
InlineCodeFeature,
HeadingFeature,
OrderedListFeature,
IndentFeature,
FixedToolbarFeature,
BlocksFeature,
} from '@payloadcms/richtext-lexical'
import { BadgeList } from '@/blocks/BadgeList/config'
export const defaultLexical = lexicalEditor({
features: [
FixedToolbarFeature(),
ParagraphFeature(),
UnderlineFeature(),
BoldFeature(),
ItalicFeature(),
AlignFeature(),
BlockquoteFeature(),
UnorderedListFeature(),
OrderedListFeature(),
IndentFeature(),
InlineCodeFeature(),
HeadingFeature(),
LinkFeature({
enabledCollections: ['pages',],
fields: ({ defaultFields }) => {
const defaultFieldsWithoutUrl = defaultFields.filter((field) => {
if ('name' in field && field.name === 'url') return false
return true
})
return [
...defaultFieldsWithoutUrl,
{
name: 'url',
type: 'text',
admin: {
condition: (_data, siblingData) => siblingData?.linkType !== 'internal',
},
label: ({ t }) => t('fields:enterURL'),
required: true,
validate: ((value, options) => {
if ((options?.siblingData as LinkFields)?.linkType === 'internal') {
return true // no validation needed, as no url should exist for internal links
}
return value ? true : 'URL is required'
}) as TextFieldSingleValidation,
},
]
},
}),
BlocksFeature({
blocks: [BadgeList],
}),
],
})

View File

@ -0,0 +1,23 @@
import { GlobalConfig } from "payload";
export const MetaGlobal: GlobalConfig = {
slug: 'meta',
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'url',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'googleVerification',
type: 'text',
},
],
}

View File

@ -0,0 +1,79 @@
import { Dock, DockIcon } from '@/components/ui/dock'
import { ModeToggle } from '@/components/mode-toggle'
import { buttonVariants } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { Nav } from '@/payload-types'
import Image from 'next/image'
type Props = { className?: string } & Nav
export default function Navbar(props: Props) {
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30 mx-auto mb-4 flex origin-bottom h-full max-h-14">
<div className="fixed bottom-0 inset-x-0 h-16 w-full bg-background to-transparent backdrop-blur-lg [-webkit-mask-image:linear-gradient(to_top,black,transparent)] dark:bg-background"></div>
<Dock className="z-50 pointer-events-auto relative mx-auto flex min-h-full h-full items-center px-1 bg-background [box-shadow:0_0_0_1px_rgba(0,0,0,.03),0_2px_4px_rgba(0,0,0,.05),0_12px_24px_rgba(0,0,0,.05)] transform-gpu dark:[border:1px_solid_rgba(255,255,255,.1)] dark:[box-shadow:0_-20px_80px_-20px_#ffffff1f_inset] ">
{props.internalLinks?.map((item) => (
<DockIcon key={item.href}>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={item.href || ''}
className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }), 'size-12')}
>
<Image
className="w-[1.2rem]"
width={24}
height={24}
src={item.iconSrc || ''}
alt={''}
/>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
</DockIcon>
))}
<Separator orientation="vertical" className="h-full" />
{props.contact?.map((c) => (
<DockIcon key={c.href}>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={c.href || ''}
className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }), 'size-12')}
>
<Image
className="w-[1.2rem]"
width={24}
height={24}
src={c.iconSrc || ''}
alt={''}
/>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>{c.label}</p>
</TooltipContent>
</Tooltip>
</DockIcon>
))}
<Separator orientation="vertical" className="h-full py-2" />
<DockIcon>
<Tooltip>
<TooltipTrigger asChild>
<ModeToggle />
</TooltipTrigger>
<TooltipContent>
<p>Theme</p>
</TooltipContent>
</Tooltip>
</DockIcon>
</Dock>
</div>
)
}

43
src/globals/Nav/config.ts Normal file
View File

@ -0,0 +1,43 @@
import { GlobalConfig } from "payload";
export const NavGlobal: GlobalConfig = {
slug: 'nav',
fields: [
{
name: 'internalLinks',
type: 'array',
fields: [
{
name: 'href',
type: 'text',
},
{
name: 'iconSrc',
type: 'text',
},
{
name: 'label',
type: 'text',
},
],
},
{
name: 'contact',
type: 'array',
fields: [
{
name: 'href',
type: 'text',
},
{
name: 'iconSrc',
type: 'text',
},
{
name: 'label',
type: 'text',
},
],
}
],
}

37
src/lib/utils.ts Normal file
View File

@ -0,0 +1,37 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: string) {
const currentDate = new Date().getTime();
if (!date.includes("T")) {
date = `${date}T00:00:00`;
}
const targetDate = new Date(date).getTime();
const timeDifference = Math.abs(currentDate - targetDate);
const daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
const fullDate = new Date(date).toLocaleString("en-us", {
month: "long",
day: "numeric",
year: "numeric",
});
if (daysAgo < 1) {
return "Today";
} else if (daysAgo < 7) {
return `${fullDate} (${daysAgo}d ago)`;
} else if (daysAgo < 30) {
const weeksAgo = Math.floor(daysAgo / 7);
return `${fullDate} (${weeksAgo}w ago)`;
} else if (daysAgo < 365) {
const monthsAgo = Math.floor(daysAgo / 30);
return `${fullDate} (${monthsAgo}mo ago)`;
} else {
const yearsAgo = Math.floor(daysAgo / 365);
return `${fullDate} (${yearsAgo}y ago)`;
}
}

View File

@ -6,24 +6,102 @@
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
pages: Page;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
db: {
defaultIDType: string;
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
globals: {
meta: Meta;
nav: Nav;
};
globalsSelect: {
meta: MetaSelect<false> | MetaSelect<true>;
nav: NavSelect<false> | NavSelect<true>;
};
globals: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
@ -48,7 +126,7 @@ export interface UserAuthOperations {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@ -65,7 +143,7 @@ export interface User {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
alt: string;
updatedAt: string;
createdAt: string;
@ -79,15 +157,202 @@ export interface Media {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: number;
slug?: string | null;
layout?: (SimpleListBlock | ShowcaseBlock | ProfileBriefBlock | SimpleBriefBlock | BadgeListBlock)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "SimpleListBlock".
*/
export interface SimpleListBlock {
listHeader?: string | null;
lineItems?:
| {
title?: string | null;
subtitle?: string | null;
description?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
extraDetail?: string | null;
avatar?: (number | null) | Media;
initials?: string | null;
link?: string | null;
isInitiallyHidden?: boolean | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'simpleList';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ShowcaseBlock".
*/
export interface ShowcaseBlock {
listName?: string | null;
heading?: string | null;
subheading?: string | null;
lineItems?: ShowcaseCardBlock[] | null;
id?: string | null;
blockName?: string | null;
blockType: 'showcase';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ShowcaseCardBlock".
*/
export interface ShowcaseCardBlock {
image?: (number | null) | Media;
video?: (number | null) | Media;
mainLink?: string | null;
title?: string | null;
unstructuredDate?: string | null;
description?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
tags?: BadgeListBlock[] | null;
links?: BadgeListBlock[] | null;
id?: string | null;
blockName?: string | null;
blockType: 'showcaseCard';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "BadgeListBlock".
*/
export interface BadgeListBlock {
listHeader?: string | null;
shouldHideListHeader?: boolean | null;
defaultColor?: string | null;
defaultTextColor?: string | null;
/**
* A valid CSS text size string
*/
textSize?: string | null;
listVariant?: ('default' | 'secondary' | 'destructive' | 'outline') | null;
badges?:
| {
value?: string | null;
color?: string | null;
/**
* String of a valid CSS color
*/
textColor?: string | null;
icon?: (number | null) | Media;
href?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'badgeList';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ProfileBriefBlock".
*/
export interface ProfileBriefBlock {
heading?: string | null;
subheading?: string | null;
avatar?: (number | null) | Media;
id?: string | null;
blockName?: string | null;
blockType: 'profileBrief';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "simpleBriefBlock".
*/
export interface SimpleBriefBlock {
title?: string | null;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'simpleBrief';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@ -107,12 +372,278 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
slug?: T;
layout?:
| T
| {
simpleList?: T | SimpleListBlockSelect<T>;
showcase?: T | ShowcaseBlockSelect<T>;
profileBrief?: T | ProfileBriefBlockSelect<T>;
simpleBrief?: T | SimpleBriefBlockSelect<T>;
badgeList?: T | BadgeListBlockSelect<T>;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "SimpleListBlock_select".
*/
export interface SimpleListBlockSelect<T extends boolean = true> {
listHeader?: T;
lineItems?:
| T
| {
title?: T;
subtitle?: T;
description?: T;
extraDetail?: T;
avatar?: T;
initials?: T;
link?: T;
isInitiallyHidden?: T;
id?: T;
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ShowcaseBlock_select".
*/
export interface ShowcaseBlockSelect<T extends boolean = true> {
listName?: T;
heading?: T;
subheading?: T;
lineItems?:
| T
| {
showcaseCard?: T | ShowcaseCardBlockSelect<T>;
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ShowcaseCardBlock_select".
*/
export interface ShowcaseCardBlockSelect<T extends boolean = true> {
image?: T;
video?: T;
mainLink?: T;
title?: T;
unstructuredDate?: T;
description?: T;
tags?:
| T
| {
badgeList?: T | BadgeListBlockSelect<T>;
};
links?:
| T
| {
badgeList?: T | BadgeListBlockSelect<T>;
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "BadgeListBlock_select".
*/
export interface BadgeListBlockSelect<T extends boolean = true> {
listHeader?: T;
shouldHideListHeader?: T;
defaultColor?: T;
defaultTextColor?: T;
textSize?: T;
listVariant?: T;
badges?:
| T
| {
value?: T;
color?: T;
textColor?: T;
icon?: T;
href?: T;
id?: T;
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ProfileBriefBlock_select".
*/
export interface ProfileBriefBlockSelect<T extends boolean = true> {
heading?: T;
subheading?: T;
avatar?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "simpleBriefBlock_select".
*/
export interface SimpleBriefBlockSelect<T extends boolean = true> {
title?: T;
content?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "meta".
*/
export interface Meta {
id: number;
name?: string | null;
url?: string | null;
description?: string | null;
googleVerification?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nav".
*/
export interface Nav {
id: number;
internalLinks?:
| {
href?: string | null;
iconSrc?: string | null;
label?: string | null;
id?: string | null;
}[]
| null;
contact?:
| {
href?: string | null;
iconSrc?: string | null;
label?: string | null;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "meta_select".
*/
export interface MetaSelect<T extends boolean = true> {
name?: T;
url?: T;
description?: T;
googleVerification?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nav_select".
*/
export interface NavSelect<T extends boolean = true> {
internalLinks?:
| T
| {
href?: T;
iconSrc?: T;
label?: T;
id?: T;
};
contact?:
| T
| {
href?: T;
iconSrc?: T;
label?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View File

@ -1,7 +1,6 @@
// storage-adapter-import-placeholder
import { postgresAdapter } from '@payloadcms/db-postgres'
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
@ -9,6 +8,10 @@ import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { defaultLexical } from './fields/defaultLexical'
import { NavGlobal } from './globals/Nav/config'
import { MetaGlobal } from './globals/Meta/config'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -20,8 +23,9 @@ export default buildConfig({
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media],
editor: lexicalEditor(),
collections: [Users, Media, Pages],
globals: [MetaGlobal, NavGlobal],
editor: defaultLexical,
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),

13
tailwind.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src /**/*.{jsx,tsx
}'
], // tell tailwind where to look
darkMode: ['selector', '[data-theme="dark"
]', '.dark'
],
theme: {
extend: {},
},
plugins: [],
}