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 DATABASE_URI=postgres://postgres:<password>@127.0.0.1:5432/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE PAYLOAD_SECRET=YOUR_SECRET_HERE
PORT=3000

View File

@ -1,38 +1,11 @@
# Payload Blank Template # Payload Personal Portfolio
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).
### Development ### Development
1. First [clone the repo](#clone) if you have not done so already 1. `cp .env.example .env` to copy the example environment variables.
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. 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 ## 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. 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: services:
payload: payload:
image: node:18-alpine image: node:18-alpine
ports: ports:
- '3000:3000' - "${PORT}:${PORT}"
volumes: volumes:
- .:/home/node/app - .:/home/node/app
- node_modules:/home/node/app/node_modules - node_modules:/home/node/app/node_modules
working_dir: /home/node/app/ working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev" command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
depends_on: # depends_on:
- mongo # - mongo
# - postgres # - postgres
env_file: env_file:
- .env - .env
# Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name # Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name
mongo: # mongo:
image: mongo:latest # image: mongo:latest
ports: # ports:
- '27017:27017' # - '27017:27017'
command: # command:
- --storageEngine=wiredTiger # - --storageEngine=wiredTiger
volumes: # volumes:
- data:/data/db # - data:/data/db
logging: # logging:
driver: none # driver: none
# Uncomment the following to use postgres # Uncomment the following to use postgres
# postgres: # postgres:

View File

@ -15,7 +15,7 @@ const eslintConfig = [
rules: { rules: {
'@typescript-eslint/ban-ts-comment': 'warn', '@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-object-type': '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': [ '@typescript-eslint/no-unused-vars': [
'warn', '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", "name": "ysandler-work",
"version": "1.0.0", "version": "1.0.0",
"description": "A blank template to get started with Payload 3.0", "description": "Personal Portfolio Site",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -15,26 +15,42 @@
"start": "cross-env NODE_OPTIONS=--no-deprecation next start" "start": "cross-env NODE_OPTIONS=--no-deprecation next start"
}, },
"dependencies": { "dependencies": {
"@payloadcms/db-postgres": "3.33.0",
"@payloadcms/next": "3.33.0", "@payloadcms/next": "3.33.0",
"@payloadcms/payload-cloud": "3.33.0", "@payloadcms/payload-cloud": "3.33.0",
"@payloadcms/richtext-lexical": "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", "cross-env": "^7.0.3",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"lucide-react": "^0.503.0",
"motion": "^12.7.4",
"next": "15.3.0", "next": "15.3.0",
"next-themes": "^0.4.6",
"payload": "3.33.0", "payload": "3.33.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-markdown": "^10.1.0",
"sharp": "0.32.6", "sharp": "0.32.6",
"@payloadcms/db-postgres": "3.33.0" "tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "19.1.0", "@types/react": "19.1.0",
"@types/react-dom": "19.1.2", "@types/react-dom": "19.1.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.16.0", "eslint": "^9.16.0",
"eslint-config-next": "15.3.0", "eslint-config-next": "15.3.0",
"postcss": "^8.5.3",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"tailwindcss": "^4.1.4",
"typescript": "5.7.3" "typescript": "5.7.3"
}, },
"engines": { "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 { ThemeProvider } from '@/components/theme-provider'
import './styles.css' 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 = { const fontSans = FontSans({
description: 'A blank template using Payload in a Next.js app.', subsets: ['latin'],
title: 'Payload Blank Template', 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 }) { export default function RootLayout({
const { children } = props children,
}: Readonly<{
children: React.ReactNode
}>) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body> <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> <main>{children}</main>
<Navbar {...navProps} />
</TooltipProvider>
</ThemeProvider>
</body> </body>
</html> </html>
) )

View File

@ -1,59 +1,3 @@
import { headers as getHeaders } from 'next/headers.js' import PageTemplate from './[slug]/page'
import Image from 'next/image'
import { getPayload } from 'payload'
import React from 'react'
import { fileURLToPath } from 'url'
import config from '@/payload.config' export default PageTemplate
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>
)
}

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) => ( const Layout = ({ children }: Args) => (
<html>
<body>
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}> <RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children} {children}
</RootLayout> </RootLayout>
</body>
</html>
) )
export default Layout 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. * 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 { export interface Config {
auth: { auth: {
users: UserAuthOperations; users: UserAuthOperations;
}; };
blocks: {};
collections: { collections: {
users: User; users: User;
media: Media; media: Media;
pages: Page;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
}; };
db: { collectionsJoins: {};
defaultIDType: string; 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; locale: null;
user: User & { user: User & {
collection: 'users'; collection: 'users';
}; };
jobs: {
tasks: unknown;
workflows: unknown;
};
} }
export interface UserAuthOperations { export interface UserAuthOperations {
forgotPassword: { forgotPassword: {
@ -48,7 +126,7 @@ export interface UserAuthOperations {
* via the `definition` "users". * via the `definition` "users".
*/ */
export interface User { export interface User {
id: string; id: number;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@ -65,7 +143,7 @@ export interface User {
* via the `definition` "media". * via the `definition` "media".
*/ */
export interface Media { export interface Media {
id: string; id: number;
alt: string; alt: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@ -79,15 +157,202 @@ export interface Media {
focalX?: number | null; focalX?: number | null;
focalY?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: string; id: number;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}; };
key?: string | null; key?: string | null;
value?: value?:
@ -107,12 +372,278 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: string; id: number;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;
createdAt: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".

View File

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