├── .nvmrc ├── src ├── app │ ├── _styles │ │ ├── global.css │ │ ├── blockquote.ts │ │ ├── skeleton.ts │ │ ├── rich-text.ts │ │ ├── fonts.ts │ │ ├── recipes │ │ │ └── link.recipe.ts │ │ └── create-fluid-value.ts │ ├── _assets │ │ └── fonts │ │ │ ├── basiercircle-regular.otf │ │ │ ├── jetbrainsmono-regular.woff2 │ │ │ ├── basiercircle-bold-webfont.woff2 │ │ │ ├── untitled-serif-bold-italic.woff2 │ │ │ ├── basiercircle-regular-webfont.woff2 │ │ │ ├── untitled-serif-regular-italic.otf │ │ │ └── untitled-serif-regular-italic.woff2 │ ├── _utils │ │ ├── constants │ │ │ ├── cms.constants.ts │ │ │ ├── cache.constants.ts │ │ │ ├── paths.constants.ts │ │ │ └── mdx.constants.tsx │ │ ├── types │ │ │ ├── icon-props.ts │ │ │ └── open-weather.ts │ │ ├── helpers │ │ │ ├── string.helpers.ts │ │ │ ├── api-route.helpers.ts │ │ │ ├── projects.helpers.ts │ │ │ └── date.helpers.ts │ │ └── og-template.tsx │ ├── writing │ │ ├── writing.module.css │ │ ├── opengraph-image.tsx │ │ ├── [slug] │ │ │ ├── opengraph-image.tsx │ │ │ ├── not-found.tsx │ │ │ ├── slug-page-mdx.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── _components │ │ ├── aspect-ratio.tsx │ │ ├── visually-hidden.tsx │ │ ├── hygraph-image-with-loader.tsx │ │ ├── not-found-content.tsx │ │ ├── separator.tsx │ │ ├── navigation │ │ │ ├── icon-components.tsx │ │ │ ├── theme-toggle.tsx │ │ │ ├── icon-button.tsx │ │ │ └── navigation.tsx │ │ ├── icons │ │ │ ├── MoonIcon.tsx │ │ │ ├── SystemIcon.tsx │ │ │ ├── ArrowTopRightIcon.tsx │ │ │ ├── ArrowLeftIcon.tsx │ │ │ ├── ArrowRightIcon.tsx │ │ │ └── SunIcon.tsx │ │ ├── image-container.tsx │ │ ├── giscus-comments.tsx │ │ ├── back-to-link.tsx │ │ ├── footer │ │ │ ├── time.tsx │ │ │ ├── weather.tsx │ │ │ └── footer.tsx │ │ ├── scroll-area.tsx │ │ ├── media.tsx │ │ ├── prose-layout.tsx │ │ └── project-card.tsx │ ├── server-sitemap-index.xml │ │ └── route.ts │ ├── opengraph-image.tsx │ ├── now │ │ ├── opengraph-image.tsx │ │ ├── [update] │ │ │ ├── not-found.tsx │ │ │ ├── opengraph-image.tsx │ │ │ ├── update-page-mdx.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── work │ │ ├── opengraph-image.tsx │ │ ├── server-sitemap.xml │ │ │ └── route.ts │ │ ├── [project] │ │ │ ├── not-found.tsx │ │ │ ├── _components │ │ │ │ └── more-projects.tsx │ │ │ └── page.tsx │ │ ├── api │ │ │ └── revalidate │ │ │ │ └── route.ts │ │ └── page.tsx │ ├── not-found.tsx │ ├── providers.tsx │ ├── _theme │ │ ├── text-styles.ts │ │ ├── semantic-tokens.ts │ │ ├── global-css.ts │ │ └── tokens.ts │ ├── layout.tsx │ └── page.tsx ├── data │ ├── updates │ │ ├── 20230129.mdx │ │ ├── 20230123.mdx │ │ ├── 20230223.mdx │ │ ├── 20240103.mdx │ │ ├── 20230130.mdx │ │ └── 20241212.mdx │ └── writings │ │ ├── website-redesign-2022.mdx │ │ ├── graphql-with-api-routes.mdx │ │ └── fluid-typography-configuration.mdx ├── pages │ └── api │ │ └── preview │ │ ├── exit.ts │ │ └── index.ts └── graphql │ ├── queries │ └── project.queries.ts │ ├── cms.ts │ └── fragments │ └── project.fragments.ts ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── public ├── favicon.ico ├── images │ ├── updates │ │ └── new-richtextarea.png │ └── writings │ │ └── website-redesign-2022 │ │ ├── portfolio-v3-v1-project-detail.jpg │ │ └── portfolio-v3-v2-project-detail.png └── vercel.svg ├── .husky └── pre-commit ├── README.md ├── .github └── dependabot.yml ├── next-env.d.ts ├── .prettierrc ├── postcss.config.cjs ├── next-sitemap.config.js ├── graphql.config.js ├── .gitignore ├── .eslintrc.json ├── codegen.ts ├── tsconfig.json ├── panda.config.ts ├── next.config.js ├── contentlayer.config.ts └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.16.0 2 | -------------------------------------------------------------------------------- /src/app/_styles/global.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint:staged 5 | -------------------------------------------------------------------------------- /public/images/updates/new-richtextarea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/public/images/updates/new-richtextarea.png -------------------------------------------------------------------------------- /src/app/_assets/fonts/basiercircle-regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/src/app/_assets/fonts/basiercircle-regular.otf -------------------------------------------------------------------------------- /src/app/_assets/fonts/jetbrainsmono-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/src/app/_assets/fonts/jetbrainsmono-regular.woff2 -------------------------------------------------------------------------------- /src/app/_utils/constants/cms.constants.ts: -------------------------------------------------------------------------------- 1 | export const CMS_URL = `https://api-us-east-1.hygraph.com/v2/${process.env.CMS_SPACE}/${process.env.CMS_ENV}`; 2 | -------------------------------------------------------------------------------- /src/app/writing/writing.module.css: -------------------------------------------------------------------------------- 1 | @media (width >= 924px) { 2 | .yearTitle { 3 | position: absolute; 4 | transform: translateX(-100%); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/app/_assets/fonts/basiercircle-bold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/src/app/_assets/fonts/basiercircle-bold-webfont.woff2 -------------------------------------------------------------------------------- /src/app/_assets/fonts/untitled-serif-bold-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/src/app/_assets/fonts/untitled-serif-bold-italic.woff2 -------------------------------------------------------------------------------- /src/app/_assets/fonts/basiercircle-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/src/app/_assets/fonts/basiercircle-regular-webfont.woff2 -------------------------------------------------------------------------------- /src/app/_assets/fonts/untitled-serif-regular-italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/src/app/_assets/fonts/untitled-serif-regular-italic.otf -------------------------------------------------------------------------------- /src/app/_assets/fonts/untitled-serif-regular-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/src/app/_assets/fonts/untitled-serif-regular-italic.woff2 -------------------------------------------------------------------------------- /src/app/_components/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import * as AspectRatio from '@radix-ui/react-aspect-ratio'; 3 | 4 | export const AspectRatioRoot = AspectRatio.Root; 5 | -------------------------------------------------------------------------------- /src/app/_utils/constants/cache.constants.ts: -------------------------------------------------------------------------------- 1 | export const TAGS = { 2 | projects: 'GetProjectsQuery', 3 | project: (slug: string) => `GetProjectQuery:${slug}`, 4 | } as const; 5 | -------------------------------------------------------------------------------- /src/app/_components/visually-hidden.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; 3 | 4 | export const VisuallyHiddenRoot = VisuallyHidden.Root; 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Portfolio V3 2 | 3 | ### Core Tech 4 | 5 | - TypeScript 6 | - React 7 | - Next.js 8 | - next-sitemap 9 | - next-themes 10 | - Panda CSS and Radix 11 | - Framer Motion 12 | -------------------------------------------------------------------------------- /src/app/_utils/types/icon-props.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface IconProps extends React.SVGAttributes { 4 | children?: never; 5 | color?: string; 6 | } 7 | -------------------------------------------------------------------------------- /public/images/writings/website-redesign-2022/portfolio-v3-v1-project-detail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/public/images/writings/website-redesign-2022/portfolio-v3-v1-project-detail.jpg -------------------------------------------------------------------------------- /public/images/writings/website-redesign-2022/portfolio-v3-v2-project-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h-jennings/portfolio-v3/HEAD/public/images/writings/website-redesign-2022/portfolio-v3-v2-project-detail.png -------------------------------------------------------------------------------- /src/data/updates/20230129.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-01-29 3 | --- 4 | 5 | Added persistance to 'now' updates. Each update is an individual mdx file that 6 | gets parsed by [Contentlayer](https://www.contentlayer.dev/). 7 | -------------------------------------------------------------------------------- /src/app/server-sitemap-index.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { getServerSideSitemapIndex } from 'next-sitemap'; 2 | 3 | export async function GET() { 4 | return getServerSideSitemapIndex([ 5 | 'https://www.hunterjennings.dev/work/server-sitemap.xml', 6 | ]); 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | target-branch: "development" 6 | schedule: 7 | interval: 'weekly' 8 | # Check for npm updates on Sundays 9 | day: 'sunday' 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /src/data/updates/20230123.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-01-23 3 | --- 4 | 5 | - I was recently promoted to Senior Frontend Developer 🎉. 6 | - Moved this page to [Contentlayer](https://www.contentlayer.dev/). This makes 7 | using MDX _a lot_ easier and worth the effort compared to other methods I've 8 | tried. 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "typescript", 6 | "tsconfig": "tsconfig.json", 7 | "option": "watch", 8 | "problemMatcher": ["$tsc-watch"], 9 | "group": "build", 10 | "label": "tsc: watch - tsconfig.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "bracketSpacing": true, 5 | "jsxSingleQuote": true, 6 | "printWidth": 80, 7 | "semi": true, 8 | "tabWidth": 2, 9 | "proseWrap": "always", 10 | "arrowParens": "always", 11 | "quoteProps": "as-needed", 12 | "bracketSameLine": false 13 | } 14 | -------------------------------------------------------------------------------- /src/app/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ogTemplate } from './_utils/og-template'; 2 | 3 | export const runtime = 'edge'; 4 | export const alt = 5 | 'Hunter Jennings - Frontend ui engineer interested in design systems, component architectures, and React.'; 6 | 7 | export default async function Image() { 8 | return await ogTemplate(); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/now/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ogTemplate } from '../_utils/og-template'; 2 | 3 | export const runtime = 'edge'; 4 | export const alt = 'Now - A snapshot of my life via short updates.'; 5 | 6 | export default async function Image() { 7 | return await ogTemplate({ 8 | title: 'Now', 9 | sub: 'A snapshot of my life via short updates.', 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | '@pandacss/dev/postcss', 4 | 'postcss-flexbugs-fixes', 5 | [ 6 | 'postcss-preset-env', 7 | { 8 | autoprefixer: { 9 | flexbox: 'no-2009', 10 | }, 11 | stage: 3, 12 | features: { 13 | 'custom-properties': false, 14 | }, 15 | }, 16 | ], 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /src/app/_styles/blockquote.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'ds/css'; 2 | 3 | export const blockquote = css({ 4 | borderLeft: '4px solid', 5 | borderColor: 'slate10', 6 | borderRadius: 5, 7 | pl: 'm', 8 | py: 'xs', 9 | bgColor: 'slate3', 10 | mb: 's', 11 | '& > p': { 12 | fontSize: '1', 13 | color: 'text1', 14 | }, 15 | '& > p:last-of-type': { 16 | m: 'none', 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/_utils/helpers/string.helpers.ts: -------------------------------------------------------------------------------- 1 | export function parseTagsToString(tags: string[]): string { 2 | return tags.reduce((acc, curr, idx) => { 3 | if (tags.length === 0) return ''; 4 | if (tags.length === 1) { 5 | return `${curr}`; 6 | } 7 | if (idx === tags.length - 1) { 8 | return `${acc}${curr}`; 9 | } 10 | return `${curr} + ${acc}`; 11 | }, ''); 12 | } 13 | -------------------------------------------------------------------------------- /src/data/updates/20230223.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-02-23 3 | --- 4 | 5 | Added syntax highlighting for code blocks for mdx files using 6 | [rehype-pretty-code](https://rehype-pretty-code.netlify.app/) 7 | 8 | It supports inline code `const test = () => console.log('hello world');{:ts}` 9 | 10 | And code blocks: 11 | 12 | ```tsx 13 | const test = () => { 14 | console.log('hello world'); 15 | }; 16 | ``` 17 | -------------------------------------------------------------------------------- /src/app/work/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ogTemplate } from '../_utils/og-template'; 2 | 3 | export const runtime = 'edge'; 4 | export const alt = 5 | 'Work - A curated collection of my work throughout the years.'; 6 | 7 | export default async function Image() { 8 | return await ogTemplate({ 9 | title: 'Work', 10 | sub: 'A curated collection of my work throughout the years.', 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/_utils/helpers/api-route.helpers.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next'; 2 | 3 | export const serverSideRedirect = ( 4 | res: NextApiResponse, 5 | destinationPath: string, 6 | statusCode = 301, 7 | ) => { 8 | res.writeHead(statusCode, { Location: destinationPath }); 9 | }; 10 | 11 | export const sendUnauthorized = (res: NextApiResponse, message: string) => 12 | res.status(401).json({ message }); 13 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: 'https://www.hunterjennings.dev', 4 | generateRobotsTxt: true, 5 | exclude: [ 6 | '/server-sitemap-index.xml', 7 | '/work/server-sitemap.xml', 8 | '/writing/element-test', 9 | ], 10 | robotsTxtOptions: { 11 | additionalSitemaps: [ 12 | 'https://www.hunterjennings.dev/server-sitemap-index.xml', 13 | ], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/writing/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ogTemplate } from '@/app/_utils/og-template'; 2 | 3 | export const runtime = 'edge'; 4 | export const alt = 5 | 'Writing - Thoughts on software, books, life, and any opinions I have at a moment in time.'; 6 | 7 | export default async function Image() { 8 | return await ogTemplate({ 9 | title: 'Writing', 10 | sub: 'Thoughts on software, books, life, and any opinions I have at a moment in time.', 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/data/updates/20240103.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2024-01-03 3 | --- 4 | 5 | For me, 2024 is going to be a year of maturing: a year of solidifying my 6 | programming foundations, following good advice, and learning to tune out the 7 | noise. Additionally, I want to expand my existing horizons and learn more about 8 | backend development. I love UI engineering and still see it as my passion, but 9 | the future is starting to feel more full-stack. I want to be prepared for it. 10 | -------------------------------------------------------------------------------- /src/app/_utils/constants/paths.constants.ts: -------------------------------------------------------------------------------- 1 | export const PATHS = { 2 | github: 'https://github.com/h-jennings', 3 | twitter: 'https://twitter.com/jennings_hunter', 4 | cv: 'https://read.cv/hunterjennings', 5 | breakline: 'https://breakline.org/', 6 | email: 'mailto:jenningsdhunter@gmail.com', 7 | base: process.env.NEXT_PUBLIC_URL ?? 'https://www.hunterjennings.dev', // TODO: might want to make this VERCEL_URL 8 | home: '/', 9 | writing: '/writing', 10 | now: '/now', 11 | work: '/work', 12 | } as const; 13 | -------------------------------------------------------------------------------- /src/app/_styles/skeleton.ts: -------------------------------------------------------------------------------- 1 | import { cva } from 'ds/css'; 2 | 3 | export const skeleton = cva({ 4 | base: { 5 | animation: 'skeleton', 6 | backgroundSize: '400% 100%', 7 | backgroundImage: 8 | 'linear-gradient(to right, var(--colors-slate1), var(--colors-slate3), var(--colors-slate3), var(--colors-slate1))', 9 | }, 10 | variants: { 11 | type: { 12 | card: { 13 | rounded: 'card', 14 | }, 15 | text: { 16 | rounded: '5px', 17 | }, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/pages/api/preview/exit.ts: -------------------------------------------------------------------------------- 1 | import { serverSideRedirect } from '@/app/_utils/helpers/api-route.helpers'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | const exit = (req: NextApiRequest, res: NextApiResponse) => { 5 | // Exit the current user from "Preview Mode". This function accepts no args. 6 | res.clearPreviewData(); 7 | 8 | // Redirect the user back to the index page. 9 | serverSideRedirect(res, req.headers.referer ?? '/', 307); 10 | 11 | res.end(); 12 | }; 13 | 14 | export default exit; 15 | -------------------------------------------------------------------------------- /src/app/work/server-sitemap.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { getProjects } from '@/app/_utils/helpers/projects.helpers'; 2 | import { getServerSideSitemap } from 'next-sitemap'; 3 | 4 | export async function GET() { 5 | // Method to source urls from cms 6 | const { projects } = await getProjects(); 7 | return getServerSideSitemap( 8 | projects.map((project) => ({ 9 | loc: `https://www.hunterjennings.dev/work/${project.slug}`, 10 | changefreq: 'daily', 11 | priority: 0.7, 12 | lastmod: new Date().toISOString(), 13 | })), 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/graphql/queries/project.queries.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import { ProjectInfoFragment } from '../fragments/project.fragments'; 3 | 4 | export const GetProject = gql` 5 | query GetProject($slug: String!) { 6 | project(where: { slug: $slug }) { 7 | ...ProjectInfo 8 | } 9 | } 10 | ${ProjectInfoFragment} 11 | `; 12 | 13 | export const GetProjects = gql` 14 | query GetProjects($count: Int = 25) { 15 | projects(first: $count, orderBy: date_DESC) { 16 | ...ProjectInfo 17 | } 18 | } 19 | ${ProjectInfoFragment} 20 | `; 21 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { NotFoundContent } from './_components/not-found-content'; 2 | import Link from 'next/link'; 3 | import { link } from 'ds/recipes'; 4 | import { css, cx } from 'ds/css'; 5 | 6 | export default function NotFound() { 7 | return ( 8 | 9 | 16 | Back to homepage 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/data/updates/20230130.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-01-30 3 | --- 4 | 5 | Started embracing AI-based tooling for my work, specifically 6 | [GitHub Copilot](https://github.com/features/copilot). Initially I was 7 | skeptical, mainly due to all of the twitter hubbub around when and where it 8 | fails miserably. Those flubs appear to be a result of over ambitious 9 | expectations, I believe. These tools work best when you let them exist in the 10 | background, relying on them only as a helpful nudge here and there when you're 11 | stuck. Plus the auto-complete suggestions are a great productivity boost. 12 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ThemeProvider } from 'next-themes'; 4 | import { Provider as BalancerProvider } from 'react-wrap-balancer'; 5 | 6 | interface ProvidersProps { 7 | children: React.ReactNode; 8 | } 9 | export const Providers = ({ children }: ProvidersProps) => { 10 | return ( 11 | 17 | {children} 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/app/_components/hygraph-image-with-loader.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image, { ImageLoaderProps, ImageProps } from 'next/image'; 4 | 5 | export const loader = ({ src, width }: ImageLoaderProps) => { 6 | const CDN_URL = `https://us-east-1.graphassets.com/${process.env.NEXT_CMS_ASSET_ENV_ID}/`; 7 | const srcInfo = src.split(CDN_URL)[1]; 8 | const transform = `resize=width:${width}/output=format:webp/`; 9 | return `${CDN_URL}${transform}${srcInfo}`; 10 | }; 11 | 12 | export const HygraphImageWithLoader = (props: ImageProps) => { 13 | return {props.alt}; 14 | }; 15 | -------------------------------------------------------------------------------- /src/app/_components/not-found-content.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'ds/css'; 2 | import { stack } from 'ds/patterns'; 3 | 4 | interface NotFoundContentProps { 5 | title?: string; 6 | children?: React.ReactNode; 7 | } 8 | export const NotFoundContent = ({ 9 | children, 10 | title = 'Page Not Found', 11 | }: NotFoundContentProps): JSX.Element => { 12 | return ( 13 |
14 |
15 |

404 - {title}

16 |
{children}
17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/app/_utils/helpers/projects.helpers.ts: -------------------------------------------------------------------------------- 1 | import { cms } from '@/graphql/cms'; 2 | import { TAGS } from '../constants/cache.constants'; 3 | 4 | export const getProjects = async (count?: number) => { 5 | return cms({ 6 | next: { 7 | tags: [TAGS.projects], 8 | }, 9 | }).GetProjects( 10 | count != null 11 | ? { 12 | count, 13 | } 14 | : undefined, 15 | ); 16 | }; 17 | 18 | export const getProject = async (project: string) => { 19 | return cms({ 20 | next: { 21 | tags: [TAGS.project(project)], 22 | }, 23 | }).GetProject({ 24 | slug: project, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/app/writing/[slug]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ogTemplate } from '@/app/_utils/og-template'; 2 | import { allWritings } from 'contentlayer/generated'; 3 | 4 | export const runtime = 'edge'; 5 | export const alt = 6 | 'Writing - Thoughts on software, books, life, and any opinions I have at a moment in time.'; 7 | 8 | export default async function Image({ params }: { params: { slug: string } }) { 9 | const writing = allWritings.find((writing) => writing.slug === params.slug); 10 | 11 | const { title, description } = writing!; 12 | return await ogTemplate({ 13 | title: title, 14 | sub: description, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /graphql.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | require('dotenv').config({ path: path.join(__dirname, '.env.local') }); 3 | 4 | module.exports = { 5 | projects: { 6 | app: { 7 | schema: ['./schema.generated.graphql'], 8 | documents: ['src/**/*.tsx', '!node_modules', '!src/graphql/**/*'], 9 | extensions: { 10 | endpoints: { 11 | default: { 12 | url: `https://api-us-east-1.hygraph.com/v2/${process.env.CMS_SPACE}/${process.env.CMS_ENV}`, 13 | headers: { Authorization: `Bearer ${process.env.CMS_PROD_TOKEN}` }, 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/app/_components/separator.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'ds/css'; 2 | import { circle, hstack } from 'ds/patterns'; 3 | 4 | export const Separator = (): JSX.Element => { 5 | return ( 6 |
13 |
19 |
20 |
21 |
22 |
23 |
24 | ); 25 | }; 26 | 27 | const dot = circle({ 28 | size: 2, 29 | bg: 'slate12', 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/now/[update]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { link } from 'ds/recipes'; 3 | import { css, cx } from 'ds/css'; 4 | import { NotFoundContent } from '@/app/_components/not-found-content'; 5 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 6 | 7 | export default function NotFound() { 8 | return ( 9 | 10 | 17 | Back to now page 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/work/[project]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { link } from 'ds/recipes'; 3 | import { css, cx } from 'ds/css'; 4 | import { NotFoundContent } from '@/app/_components/not-found-content'; 5 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 6 | 7 | export default function NotFound() { 8 | return ( 9 | 10 | 17 | View all work 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/writing/[slug]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { link } from 'ds/recipes'; 3 | import { css, cx } from 'ds/css'; 4 | import { NotFoundContent } from '@/app/_components/not-found-content'; 5 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 6 | 7 | export default function NotFound() { 8 | return ( 9 | 10 | 17 | View all writings 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/_components/navigation/icon-components.tsx: -------------------------------------------------------------------------------- 1 | import { MoonIcon } from '@/app/_components/icons/MoonIcon'; 2 | import { SunIcon } from '@/app/_components/icons/SunIcon'; 3 | import { SystemIcon } from '@/app/_components/icons/SystemIcon'; 4 | 5 | export type Theme = 'system' | 'light' | 'dark'; 6 | 7 | export const ICON_SVG_COMPONENTS: Record< 8 | Theme, 9 | { label: string; icon: () => JSX.Element } 10 | > = { 11 | dark: { 12 | label: 'dark theme', 13 | icon: () => , 14 | }, 15 | system: { 16 | label: 'system theme', 17 | icon: () => , 18 | }, 19 | light: { 20 | label: 'light theme', 21 | icon: () => , 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/now/[update]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ogTemplate } from '@/app/_utils/og-template'; 2 | import { parseDateToLongDateString } from '@/app/_utils/helpers/date.helpers'; 3 | import { allUpdates } from 'contentlayer/generated'; 4 | 5 | export const runtime = 'edge'; 6 | export const alt = 'Now - An update from my life'; 7 | 8 | export default async function Image({ 9 | params, 10 | }: { 11 | params: { update: string }; 12 | }) { 13 | const update = allUpdates.find((update) => update.slug === params.update); 14 | const fancyDate = parseDateToLongDateString(update!.date); 15 | 16 | return await ogTemplate({ 17 | title: 'Now', 18 | sub: `A snapshot of my life—${fancyDate}.`, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/data/updates/20241212.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2024-12-12 3 | --- 4 | 5 | Spent the past week diving into [Lexical](https://lexical.dev/) and exploring the world of text editor frameworks. After a lot of reading and tinkering with open-source code, I built a feature-complete replacement for our legacy `` component (previously based on the now deprecated `react-rte`). 6 | 7 | 8 | 9 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "pnpm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "pnpm run dev", 21 | "serverReadyAction": { 22 | "pattern": "started server on .+, url: (https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/app/_components/icons/MoonIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@/app/_utils/types/icon-props'; 2 | import * as React from 'react'; 3 | 4 | export const MoonIcon = React.forwardRef( 5 | ({ color = 'currentColor', ...props }, forwardedRef) => { 6 | return ( 7 | 16 | Dark Theme Icon 17 | 21 | 22 | ); 23 | }, 24 | ); 25 | 26 | MoonIcon.displayName = 'MoonIcon'; 27 | -------------------------------------------------------------------------------- /src/app/_styles/rich-text.ts: -------------------------------------------------------------------------------- 1 | import { css, cx } from 'ds/css'; 2 | 3 | export const inlineElementReset = css({ 4 | fontSize: 'inherit', 5 | color: 'inherit', 6 | lineHeight: 'inherit', 7 | }); 8 | 9 | export const unorderedList = css({ 10 | listStyleType: 'disc', 11 | color: 'text1', 12 | pl: 'm', 13 | mb: 'm', 14 | '& ul': { 15 | pl: 'm', 16 | }, 17 | '& ol, ul': { 18 | mb: '2xs', 19 | }, 20 | }); 21 | 22 | export const orderedList = cx( 23 | unorderedList, 24 | css({ 25 | listStyleType: 'decimal', 26 | }), 27 | ); 28 | 29 | export const listItem = css({ 30 | ml: 'none', 31 | mb: 'xs', 32 | pl: 'none', 33 | _lastOfType: { 34 | mb: 'none', 35 | }, 36 | '& > ul li:first-of-type, ol li:first-of-type': { 37 | pt: 'xs', 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/app/_components/image-container.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { css } from 'ds/css'; 3 | import { stack } from 'ds/patterns'; 4 | import * as React from 'react'; 5 | 6 | interface ImageContainerProps { 7 | caption?: string; 8 | } 9 | export const ImageContainer = ({ 10 | caption, 11 | children, 12 | }: React.PropsWithChildren) => { 13 | return ( 14 |
15 |
22 | {children} 23 |
24 | {caption != null && ( 25 |

26 | {caption} 27 |

28 | )} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/app/_components/giscus-comments.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Giscus from '@giscus/react'; 4 | import { useTheme } from 'next-themes'; 5 | import { Theme } from './navigation/icon-components'; 6 | 7 | export const GiscusComments = () => { 8 | const { theme } = useTheme(); 9 | return ( 10 | 24 | ); 25 | }; 26 | 27 | const THEME_LOOKUP = { 28 | light: 'light', 29 | dark: 'dark', 30 | system: 'preferred_color_scheme', 31 | } as const; 32 | -------------------------------------------------------------------------------- /src/app/_theme/text-styles.ts: -------------------------------------------------------------------------------- 1 | import { defineTextStyles } from '@pandacss/dev'; 2 | 3 | export const textStyles = defineTextStyles({ 4 | base: { 5 | value: { 6 | lineHeight: 'body', 7 | fontSize: '2', 8 | fontWeight: 'regular', 9 | fontFamily: 'primary', 10 | }, 11 | }, 12 | serif: { 13 | value: { 14 | lineHeight: 'body', 15 | fontSize: '2', 16 | fontWeight: 'regular', 17 | fontFamily: 'serif', 18 | fontStyle: 'italic', 19 | }, 20 | }, 21 | heading: { 22 | value: { 23 | lineHeight: 'body', 24 | fontSize: '4', 25 | fontWeight: 'regular', 26 | fontFamily: 'primary', 27 | }, 28 | }, 29 | body: { 30 | value: { 31 | lineHeight: 'body', 32 | fontSize: '2', 33 | fontWeight: 'regular', 34 | fontFamily: 'primary', 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development 30 | .env.test 31 | .env.production 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | # Seo stuff created at build time 40 | public/sitemap.xml 41 | public/sitemap-?.xml 42 | public/robots.txt 43 | 44 | # No need to track cached files into source control 45 | **/*.db 46 | 47 | # Generated 48 | schema.generated.graphql 49 | src/graphql/generated/ 50 | 51 | .contentlayer 52 | 53 | # Panda 54 | ds -------------------------------------------------------------------------------- /src/app/writing/[slug]/slug-page-mdx.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Image from 'next/image'; 3 | import { getMDXComponent } from 'next-contentlayer/hooks'; 4 | import { ImageContainer } from '@/app/_components/image-container'; 5 | import { AspectRatio } from '@radix-ui/react-aspect-ratio'; 6 | import { MDX_ELEMENTS } from '@/app/_utils/constants/mdx.constants'; 7 | 8 | interface SlugPageMDXProps { 9 | code: string; 10 | } 11 | // TODO: #144 temporary due to a bug in contentlayer where it doesn't allow for custom components to render in rsc 12 | export const SlugPageMDX = ({ code }: SlugPageMDXProps) => { 13 | const MDXContent = getMDXComponent(code); 14 | 15 | // @ts-expect-error - Library types are incorrect 16 | return ; 17 | }; 18 | 19 | const MDX_COMPONENTS = { 20 | Image, 21 | ImageContainer, 22 | AspectRatio, 23 | } as const; 24 | -------------------------------------------------------------------------------- /src/app/now/[update]/update-page-mdx.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Image from 'next/image'; 3 | import { getMDXComponent } from 'next-contentlayer/hooks'; 4 | import { ImageContainer } from '@/app/_components/image-container'; 5 | import { AspectRatio } from '@radix-ui/react-aspect-ratio'; 6 | import { MDX_ELEMENTS } from '@/app/_utils/constants/mdx.constants'; 7 | 8 | interface UpdatePageMDXProps { 9 | code: string; 10 | } 11 | // TODO: #144 temporary due to a bug in contentlayer where it doesn't allow for custom components to render in rsc 12 | export const UpdatePageMDX = ({ code }: UpdatePageMDXProps) => { 13 | const MDXContent = getMDXComponent(code); 14 | 15 | // @ts-expect-error - Library types are incorrect 16 | return ; 17 | }; 18 | 19 | const MDX_COMPONENTS = { 20 | Image, 21 | ImageContainer, 22 | AspectRatio, 23 | } as const; 24 | -------------------------------------------------------------------------------- /src/graphql/cms.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | import { draftMode } from 'next/headers'; 3 | import 'server-only'; 4 | import { getSdk } from './generated/cms.generated'; 5 | import { CMS_URL } from '@/app/_utils/constants/cms.constants'; 6 | 7 | const getAuthHeader = () => { 8 | const { isEnabled } = draftMode(); 9 | return `Bearer ${ 10 | !isEnabled ? process.env.CMS_PROD_TOKEN! : process.env.CMS_PREVIEW_TOKEN! 11 | }`; 12 | }; 13 | 14 | const client = ({ next }: { next?: NextFetchRequestConfig }) => { 15 | return new GraphQLClient(CMS_URL, { 16 | headers: { 17 | Authorization: getAuthHeader(), 18 | }, 19 | fetch, // Need to pass fetch here because of Next.js cache 20 | next, // Allows us to control the cache on a per-request basis 21 | }); 22 | }; 23 | 24 | export const cms = ({ next }: { next?: NextFetchRequestConfig } = {}) => 25 | getSdk(client({ next })); 26 | -------------------------------------------------------------------------------- /src/graphql/fragments/project.fragments.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | 3 | export const ProjectInfoFragment = gql` 4 | fragment ProjectInfo on Project { 5 | id 6 | seo { 7 | title 8 | image { 9 | url 10 | } 11 | description 12 | hideFromSearch 13 | } 14 | slug 15 | name 16 | client { 17 | name 18 | } 19 | category 20 | featured 21 | featureMediaNarrow { 22 | id 23 | mediaType 24 | url 25 | width 26 | height 27 | } 28 | featureMediaWide { 29 | id 30 | mediaType 31 | url 32 | width 33 | height 34 | } 35 | media { 36 | id 37 | mediaType 38 | url 39 | width 40 | height 41 | } 42 | description { 43 | raw 44 | } 45 | descriptionLong { 46 | raw 47 | } 48 | contribution 49 | date 50 | link 51 | } 52 | `; 53 | -------------------------------------------------------------------------------- /src/app/_components/icons/SystemIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@/app/_utils/types/icon-props'; 2 | import * as React from 'react'; 3 | 4 | export const SystemIcon = React.forwardRef( 5 | ({ color = 'currentColor', ...props }, forwardedRef) => { 6 | return ( 7 | <> 8 | 17 | System Theme Icon 18 | 24 | 25 | 26 | ); 27 | }, 28 | ); 29 | 30 | SystemIcon.displayName = 'SystemIcon'; 31 | -------------------------------------------------------------------------------- /src/pages/api/preview/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sendUnauthorized, 3 | serverSideRedirect, 4 | } from '@/app/_utils/helpers/api-route.helpers'; 5 | import { type NextApiRequest, type NextApiResponse } from 'next'; 6 | 7 | const preview = (req: NextApiRequest, res: NextApiResponse) => { 8 | const { secret, slug } = req.query; 9 | 10 | if ( 11 | secret !== process.env.NEXT_PREVIEW_SECRET || 12 | slug == null || 13 | Array.isArray(slug) 14 | ) { 15 | return sendUnauthorized(res, 'Invalid token or slug not provided'); 16 | } 17 | 18 | // 19 | 20 | /** 21 | * Calling setPreviewData sets a preview cookies that turn on the preview mode. 22 | * Any requests to Next.js containing these cookies will be seen as preview mode 23 | */ 24 | res.setPreviewData({ 25 | maxAge: 60 * 60, // The preview mode cookies expire in 1 hour 26 | }); 27 | 28 | serverSideRedirect(res, slug, 307); 29 | 30 | res.end(); 31 | }; 32 | 33 | export default preview; 34 | -------------------------------------------------------------------------------- /src/app/_components/navigation/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { circle, grid } from 'ds/patterns'; 3 | import { LayoutGroup } from 'framer-motion'; 4 | import { ICON_SVG_COMPONENTS, Theme } from './icon-components'; 5 | import dynamic from 'next/dynamic'; 6 | 7 | const IconButton = dynamic(() => import('./icon-button'), { 8 | ssr: false, 9 | loading: () =>
, 10 | }); 11 | 12 | const ThemeToggle = () => { 13 | return ( 14 |
26 | 27 | {Object.keys(ICON_SVG_COMPONENTS).map((key) => ( 28 | 29 | ))} 30 | 31 |
32 | ); 33 | }; 34 | 35 | export default ThemeToggle; 36 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "parserOptions": { 5 | "project": ["./tsconfig.json"] 6 | }, 7 | "extends": [ 8 | "next/core-web-vitals", 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | "plugin:@typescript-eslint/strict", 11 | "prettier" 12 | ], 13 | "rules": { 14 | "no-unused-vars": "off", 15 | "no-console": "warn", 16 | "no-debugger": "error", 17 | "no-constant-binary-expression": "warn", 18 | "no-implicit-coercion": "warn", 19 | "no-unneeded-ternary": "warn", 20 | "@typescript-eslint/strict-boolean-expressions": "error", 21 | "@typescript-eslint/no-unused-vars": [ 22 | "error", 23 | { 24 | "argsIgnorePattern": "^_", 25 | "varsIgnorePattern": "^_", 26 | "caughtErrorsIgnorePattern": "^_" 27 | } 28 | ], 29 | "@typescript-eslint/prefer-reduce-type-parameter": "off", 30 | "@typescript-eslint/restrict-template-expressions": "off" 31 | }, 32 | "ignorePatterns": ["**/*.js", "src/graphql/generated/*"] 33 | } 34 | -------------------------------------------------------------------------------- /src/app/_components/back-to-link.tsx: -------------------------------------------------------------------------------- 1 | import ArrowLeftIcon from '@/app/_components/icons/ArrowLeftIcon'; 2 | import { css } from 'ds/css'; 3 | import { hstack, linkBox, linkOverlay } from 'ds/patterns'; 4 | import { token } from 'ds/tokens'; 5 | import Link from 'next/link'; 6 | import * as React from 'react'; 7 | 8 | interface BackToLinkProps { 9 | href: string; 10 | } 11 | 12 | export const BackToLink = ({ 13 | children, 14 | href, 15 | }: React.PropsWithChildren): JSX.Element => { 16 | return ( 17 |
18 |
24 | 25 | 26 | 30 | {children} 31 | 32 | 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/app/_components/icons/ArrowTopRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@/app/_utils/types/icon-props'; 2 | import * as React from 'react'; 3 | 4 | export const ArrowTopRightIcon = React.forwardRef( 5 | ({ color = 'currentColor', ...props }, forwardedRef) => { 6 | return ( 7 | 16 | 22 | 23 | ); 24 | }, 25 | ); 26 | 27 | ArrowTopRightIcon.displayName = 'ArrowTopRightIcon'; 28 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from '@graphql-codegen/cli'; 2 | 3 | const config: CodegenConfig = { 4 | schema: [ 5 | { 6 | [`https://api-us-east-1.hygraph.com/v2/${process.env.CMS_SPACE}/${process.env.CMS_ENV}`]: 7 | { 8 | headers: { 9 | Authorization: `Bearer ${process.env.CMS_PROD_TOKEN!}`, 10 | }, 11 | }, 12 | }, 13 | ], 14 | documents: ['src/graphql/{queries,fragments}/**/*.ts', '!node_modules'], 15 | hooks: { 16 | afterAllFileWrite: 'prettier --write', 17 | }, 18 | generates: { 19 | 'src/graphql/generated/cms.generated.ts': { 20 | config: { 21 | avoidOptionals: true, 22 | }, 23 | plugins: [ 24 | { 25 | add: { 26 | content: `// DO NOT EDIT 27 | // This file is automatically generated, run yarn gen to update 28 | `, 29 | }, 30 | }, 31 | 'typescript', 32 | 'typescript-operations', 33 | 'typescript-graphql-request', 34 | ], 35 | }, 36 | './schema.generated.graphql': { 37 | plugins: ['schema-ast'], 38 | }, 39 | }, 40 | }; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /src/app/_components/icons/ArrowLeftIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@/app/_utils/types/icon-props'; 2 | import * as React from 'react'; 3 | 4 | export const ArrowLeftIcon = React.forwardRef( 5 | ({ color = 'currentColor', ...props }, forwardedRef) => { 6 | return ( 7 | 16 | 22 | 23 | ); 24 | }, 25 | ); 26 | 27 | ArrowLeftIcon.displayName = 'ArrowLeftIcon'; 28 | 29 | export default ArrowLeftIcon; 30 | -------------------------------------------------------------------------------- /src/app/_components/icons/ArrowRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@/app/_utils/types/icon-props'; 2 | import * as React from 'react'; 3 | 4 | export const ArrowRightIcon = React.forwardRef( 5 | ({ color = 'currentColor', ...props }, forwardedRef) => { 6 | return ( 7 | 16 | 22 | 23 | ); 24 | }, 25 | ); 26 | 27 | ArrowRightIcon.displayName = 'ArrowRightIcon'; 28 | 29 | export default ArrowRightIcon; 30 | -------------------------------------------------------------------------------- /src/app/_theme/semantic-tokens.ts: -------------------------------------------------------------------------------- 1 | import { defineSemanticTokens } from '@pandacss/dev'; 2 | import { 3 | goldDark, 4 | greenDark, 5 | slateDark, 6 | yellowDark, 7 | tomatoDark, 8 | gold, 9 | green, 10 | slate, 11 | tomato, 12 | yellow, 13 | } from '@radix-ui/colors'; 14 | 15 | const colorsDark = { 16 | ...goldDark, 17 | ...greenDark, 18 | ...slateDark, 19 | ...greenDark, 20 | ...yellowDark, 21 | ...tomatoDark, 22 | } as const; 23 | 24 | export const colorsLight = { 25 | ...gold, 26 | ...green, 27 | ...slate, 28 | ...green, 29 | ...yellow, 30 | ...tomato, 31 | } as const; 32 | 33 | type ColorKey = keyof typeof colorsDark; 34 | 35 | const colors = Object.keys(colorsDark).reduce((acc, key) => { 36 | acc[key as ColorKey] = { 37 | value: { 38 | base: colorsLight[key as ColorKey], 39 | _dark: colorsDark[key as ColorKey], 40 | }, 41 | }; 42 | return acc; 43 | }, {} as Record); 44 | 45 | export const semanticTokens = defineSemanticTokens({ 46 | colors: { 47 | ...colors, 48 | text1: { 49 | value: { 50 | base: 'black', 51 | _dark: 'white', 52 | }, 53 | }, 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noUncheckedIndexedAccess": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "baseUrl": ".", 18 | "paths": { 19 | "contentlayer/generated": ["./.contentlayer/generated"], 20 | "@common/*": ["./src/modules/common/*"], 21 | "@components/*": ["./src/components/*"], 22 | "@utils/*": ["./src/utils/*"], 23 | "@home/*": ["./src/modules/home/*"], 24 | "@work/*": ["./src/modules/work/*"], 25 | "@assets/*": ["./src/assets/*"], 26 | "@/*": ["./src/*"] 27 | }, 28 | "incremental": true, 29 | "plugins": [ 30 | { 31 | "name": "next" 32 | } 33 | ] 34 | }, 35 | "include": [ 36 | "next-env.d.ts", 37 | ".contentlayer/generated", 38 | "**/*.ts", 39 | "**/*.tsx", 40 | ".next/types/**/*.ts" 41 | ], 42 | "exclude": ["node_modules"] 43 | } 44 | -------------------------------------------------------------------------------- /panda.config.ts: -------------------------------------------------------------------------------- 1 | import { linkRecipe } from '@/app/_styles/recipes/link.recipe'; 2 | import { globalCss } from '@/app/_theme/global-css'; 3 | import { semanticTokens } from '@/app/_theme/semantic-tokens'; 4 | import { textStyles } from '@/app/_theme/text-styles'; 5 | import { tokens } from '@/app/_theme/tokens'; 6 | import { defineConfig } from '@pandacss/dev'; 7 | 8 | export default defineConfig({ 9 | preflight: true, 10 | include: ['./src/app/**/*.{js,jsx,ts,tsx}'], 11 | theme: { 12 | tokens, 13 | semanticTokens, 14 | textStyles, 15 | breakpoints: { 16 | bp1: '520px', 17 | bp2: '768px', 18 | bp3: '1040px', 19 | bp4: '1800px', 20 | }, 21 | extend: { 22 | recipes: { 23 | link: linkRecipe, 24 | }, 25 | keyframes: { 26 | skeleton: { 27 | '0%': { 28 | backgroundPosition: '200% 0%', 29 | }, 30 | '100%': { 31 | backgroundPosition: '-200% 0%', 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | strictTokens: false, 38 | globalCss, 39 | outdir: 'ds', 40 | jsxFramework: 'react', 41 | conditions: { 42 | extend: { 43 | dark: '.dark-theme &', 44 | light: '.light-theme &', 45 | }, 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /src/app/_styles/fonts.ts: -------------------------------------------------------------------------------- 1 | import localFont from 'next/font/local'; 2 | 3 | export const basierCircle = localFont({ 4 | display: 'swap', 5 | preload: true, 6 | fallback: ['system-ui', 'sans-serif'], 7 | variable: '--font-sans', 8 | src: [ 9 | { 10 | path: '../_assets/fonts/basiercircle-regular-webfont.woff2', 11 | weight: '400', 12 | }, 13 | { 14 | path: '../_assets/fonts/basiercircle-bold-webfont.woff2', 15 | weight: '700', 16 | }, 17 | ], 18 | }); 19 | 20 | export const untitledSerif = localFont({ 21 | display: 'swap', 22 | preload: false, 23 | fallback: ['serif'], 24 | variable: '--font-serif', 25 | src: [ 26 | { 27 | path: '../_assets/fonts/untitled-serif-regular-italic.woff2', 28 | weight: '400', 29 | style: 'italic', 30 | }, 31 | { 32 | path: '../_assets/fonts/untitled-serif-bold-italic.woff2', 33 | weight: '700', 34 | style: 'italic', 35 | }, 36 | ], 37 | }); 38 | 39 | export const jetbrainsMono = localFont({ 40 | display: 'swap', 41 | preload: false, 42 | fallback: ['monospace'], 43 | variable: '--font-mono', 44 | src: [ 45 | { 46 | path: '../_assets/fonts/jetbrainsmono-regular.woff2', 47 | weight: '400', 48 | }, 49 | ], 50 | }); 51 | -------------------------------------------------------------------------------- /src/app/_components/icons/SunIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@/app/_utils/types/icon-props'; 2 | import * as React from 'react'; 3 | 4 | export const SunIcon = React.forwardRef( 5 | ({ color = 'currentColor', ...props }, forwardedRef) => { 6 | return ( 7 | 16 | Light Theme Icon 17 | 18 | 24 | 25 | ); 26 | }, 27 | ); 28 | 29 | SunIcon.displayName = 'SunIcon'; 30 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { withContentlayer } = require('next-contentlayer'); 2 | 3 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 4 | enabled: process.env.ANALYZE === 'true', 5 | }); 6 | 7 | /** @type {import('next').NextConfig} */ 8 | const nextConfig = { 9 | swcMinify: true, 10 | reactStrictMode: true, 11 | outputFileTracing: true, 12 | env: { 13 | NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL, 14 | NEXT_CMS_ASSET_ENV_ID: process.env.NEXT_CMS_ASSET_ENV_ID, 15 | }, 16 | images: { 17 | remotePatterns: [ 18 | { 19 | protocol: 'https', 20 | hostname: '**.graphassets.com', 21 | }, 22 | ], 23 | domains: ['media.graphassets.com'], 24 | formats: ['image/webp'], 25 | }, 26 | async redirects() { 27 | return [ 28 | { 29 | source: '/about', 30 | destination: '/', 31 | permanent: true, 32 | }, 33 | { 34 | source: '/dod', 35 | destination: '/work/dwr', 36 | permanent: true, 37 | }, 38 | { 39 | source: '/portfolio-v1', 40 | destination: 'https://v2.hunterjennings.dev/portfolio-v1', 41 | permanent: true, 42 | }, 43 | { 44 | source: '/caffeinator', 45 | destination: 'https://v2.hunterjennings.dev/caffeinator', 46 | permanent: true, 47 | }, 48 | ]; 49 | }, 50 | }; 51 | module.exports = withBundleAnalyzer(withContentlayer(nextConfig)); 52 | -------------------------------------------------------------------------------- /src/app/_components/footer/time.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { css } from 'ds/css'; 3 | import * as React from 'react'; 4 | 5 | const Time = () => { 6 | const now = new Date().getTime(); 7 | 8 | const [currentTime, setTime] = React.useState<{ 9 | pretty: string; 10 | twentyFour: string; 11 | }>(() => { 12 | return { 13 | pretty: formatPretty(now), 14 | twentyFour: formatTwentyFour(now), 15 | }; 16 | }); 17 | 18 | React.useEffect(() => { 19 | const tick = setInterval(() => { 20 | const now = new Date().getTime(); 21 | setTime({ pretty: formatPretty(now), twentyFour: formatTwentyFour(now) }); 22 | }, 1000); 23 | 24 | return () => clearInterval(tick); 25 | }, []); 26 | return ( 27 | 33 | ); 34 | }; 35 | 36 | const formatPretty = (date: number) => { 37 | return formatter.format(date); 38 | }; 39 | const formatTwentyFour = (date: number) => { 40 | return formatterTwentyFour.format(date); 41 | }; 42 | 43 | const formatter = new Intl.DateTimeFormat('en', { 44 | timeZone: 'America/New_York', 45 | timeStyle: 'short', 46 | }); 47 | 48 | const formatterTwentyFour = new Intl.DateTimeFormat('en', { 49 | timeZone: 'America/New_York', 50 | timeStyle: 'short', 51 | hour12: false, 52 | }); 53 | 54 | export default Time; 55 | -------------------------------------------------------------------------------- /src/app/_components/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 3 | import { css } from 'ds/css'; 4 | import { flex } from 'ds/patterns'; 5 | 6 | export const ScrollAreaRoot = ScrollAreaPrimitive.Root; 7 | export const ScrollAreaViewport = ScrollAreaPrimitive.Viewport; 8 | export const ScrollAreaScrollbar = ScrollAreaPrimitive.Scrollbar; 9 | export const ScrollAreaThumb = ScrollAreaPrimitive.Thumb; 10 | 11 | export const SCROLLBAR_SIZE = 10; 12 | 13 | export const rootStyles = css({ 14 | h: 'full', 15 | w: 'full', 16 | overflow: 'hidden', 17 | }); 18 | 19 | export const viewportStyles = css({ 20 | h: 'full', 21 | w: 'full', 22 | borderRadius: 'inherit', 23 | }); 24 | 25 | export const barStyles = flex({ 26 | bgColor: 'uiBg', 27 | userSelect: 'none', 28 | touchAction: 'none', 29 | p: 2, 30 | h: SCROLLBAR_SIZE, 31 | transition: 'background-color 160ms ease-out', 32 | '&[data-orientation="horizontal"]': { 33 | flexDirection: 'column', 34 | }, 35 | _hover: { 36 | bgColor: 'slate4', 37 | }, 38 | }); 39 | 40 | export const thumbStyles = css({ 41 | backgroundColor: 'surface2', 42 | pos: 'relative', 43 | flex: '1', 44 | borderRadius: SCROLLBAR_SIZE, 45 | _before: { 46 | content: '', 47 | pos: 'absolute', 48 | top: '50%', 49 | left: '50%', 50 | transform: 'translate(-50%, -50%)', 51 | w: '100%', 52 | h: '100%', 53 | minW: 44, 54 | minH: 44, 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /src/app/_utils/helpers/date.helpers.ts: -------------------------------------------------------------------------------- 1 | import { Writing } from 'contentlayer/generated'; 2 | import { compareDesc, format, parseISO } from 'date-fns'; 3 | 4 | export const getYearFromDate = (date: string): string => { 5 | return new Date(date).getFullYear().toString(); 6 | }; 7 | export const parseDateToString = (date: string): string => { 8 | return format(parseISO(date), 'yyyy-MM-dd'); 9 | }; 10 | export const parseDateToLongDateString = (date: string): string => { 11 | return format(parseISO(date), 'LLLL dd, yyyy '); 12 | }; 13 | 14 | export function sortArrayByDateDesc( 15 | arr: TArray, 16 | ) { 17 | if (!Array.isArray(arr)) return []; 18 | 19 | return [...arr].sort(({ date: a }, { date: b }) => 20 | compareDesc(new Date(a), new Date(b)), 21 | ); 22 | } 23 | 24 | type Writings = (Writing & { year: string })[]; 25 | export const groupDatesByYear = (writings: Writings) => { 26 | return Object.entries( 27 | writings.reduce((result, value) => { 28 | if (result[value.year] === undefined) { 29 | result[value.year] = []; 30 | } 31 | 32 | result[value.year]?.push(value); 33 | 34 | return result; 35 | }, {} as Record), 36 | ) 37 | .map(([key, value]) => ({ 38 | year: key, 39 | writings: value, 40 | })) 41 | .reverse(); 42 | }; 43 | 44 | export const addYearToWritings = (writings: Writing[]) => { 45 | return writings.map((writing) => { 46 | return { 47 | ...writing, 48 | year: getYearFromDate(writing.date), 49 | }; 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /src/app/_utils/types/open-weather.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const Clouds = z.object({ 4 | all: z.number().optional(), 5 | }); 6 | 7 | const Coord = z.object({ 8 | lon: z.number().optional(), 9 | lat: z.number().optional(), 10 | }); 11 | 12 | const Main = z.object({ 13 | temp: z.number(), 14 | pressure: z.number().optional(), 15 | feels_like: z.number().optional(), 16 | humidity: z.number().optional(), 17 | temp_min: z.number().optional(), 18 | temp_max: z.number().optional(), 19 | }); 20 | 21 | const Sys = z.object({ 22 | type: z.number().optional(), 23 | id: z.number().optional(), 24 | message: z.number().optional(), 25 | country: z.string().optional(), 26 | sunrise: z.number().optional(), 27 | sunset: z.number().optional(), 28 | }); 29 | 30 | const Weather = z.object({ 31 | id: z.number(), 32 | main: z.string(), 33 | description: z.string(), 34 | icon: z.string(), 35 | }); 36 | 37 | const Wind = z.object({ 38 | speed: z.number().optional(), 39 | deg: z.number().optional(), 40 | gust: z.number().optional(), 41 | }); 42 | 43 | export const OpenWeatherResponseZod = z.object({ 44 | coord: Coord, 45 | weather: z.array(Weather), 46 | base: z.string(), 47 | main: Main, 48 | visibility: z.number(), 49 | wind: Wind, 50 | clouds: Clouds, 51 | dt: z.number(), 52 | sys: Sys, 53 | id: z.number(), 54 | timezone: z.number().optional(), 55 | name: z.string(), 56 | cod: z.number(), 57 | }); 58 | 59 | export type OpenWeatherResponse = z.infer; 60 | 61 | export interface WeatherData { 62 | temp: number | undefined; 63 | icon: string | undefined; 64 | description: string | undefined; 65 | } 66 | -------------------------------------------------------------------------------- /src/app/_components/media.tsx: -------------------------------------------------------------------------------- 1 | import { HygraphImageWithLoader } from '@/app/_components/hygraph-image-with-loader'; 2 | import { AspectRatioRoot } from './aspect-ratio'; 3 | 4 | interface MediaProps { 5 | width: number; 6 | height: number; 7 | url: string; 8 | sizes?: string; 9 | } 10 | 11 | export type ImageProps = { 12 | type: 'IMAGE'; 13 | } & MediaProps; 14 | 15 | type VideoProps = { 16 | type: 'VIDEO'; 17 | } & MediaProps; 18 | 19 | export const Media = (props: ImageProps | VideoProps) => { 20 | const Component = (() => { 21 | switch (props.type) { 22 | case 'IMAGE': 23 | return ; 24 | case 'VIDEO': 25 | return ; 26 | default: 27 | return null; 28 | } 29 | })(); 30 | 31 | return Component; 32 | }; 33 | 34 | const ImageMedia = (props: ImageProps) => { 35 | const { url, width, height, sizes } = props; 36 | return ( 37 | 38 | 46 | 47 | ); 48 | }; 49 | 50 | const VideoMedia = (props: VideoProps) => { 51 | const { url, width, height } = props; 52 | return ( 53 | 54 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/app/_components/navigation/icon-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { cva, cx } from 'ds/css'; 3 | import { circle, grid } from 'ds/patterns'; 4 | import { motion } from 'framer-motion'; 5 | import { useTheme } from 'next-themes'; 6 | import { ICON_SVG_COMPONENTS, Theme } from './icon-components'; 7 | 8 | interface IconButtonProps { 9 | icon: Theme; 10 | } 11 | const IconButton = ({ icon }: IconButtonProps) => { 12 | const { theme, setTheme } = useTheme(); 13 | const isActive = theme === icon; 14 | 15 | const Icon = ICON_SVG_COMPONENTS[icon].icon; 16 | const buttonLabel = ICON_SVG_COMPONENTS[icon].label; 17 | 18 | return ( 19 |
27 | 34 | {isActive ? ( 35 | 40 | ) : null} 41 |
42 | ); 43 | }; 44 | 45 | const iconButton = cva({ 46 | base: { 47 | display: 'block', 48 | transitionDuration: 'default', 49 | transitionTimingFunction: 'default', 50 | transitionProperty: 'color', 51 | gridArea: '1 / 1 / 1 / 1', 52 | zIndex: 2, 53 | }, 54 | variants: { 55 | isActive: { 56 | true: { 57 | color: 'slate12', 58 | }, 59 | false: { 60 | color: 'slate8', 61 | }, 62 | }, 63 | }, 64 | }); 65 | 66 | export default IconButton; 67 | -------------------------------------------------------------------------------- /src/app/work/api/revalidate/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | import { TAGS } from '@/app/_utils/constants/cache.constants'; 5 | import { verifyWebhookSignature } from '@hygraph/utils'; 6 | import { revalidateTag } from 'next/cache'; 7 | import { headers } from 'next/headers'; 8 | import { NextRequest, NextResponse } from 'next/server'; 9 | 10 | export async function POST(request: NextRequest) { 11 | const body = await request.json(); 12 | const signature = headers().get('gcms-signature'); 13 | const secret = process.env.CMS_WEBHOOK_SECRET; 14 | 15 | if (signature == null) { 16 | return NextResponse.json({ 17 | status: 401, 18 | message: 'Signature does not exist', 19 | }); 20 | } 21 | 22 | if (secret == null) { 23 | return NextResponse.json({ 24 | status: 401, 25 | message: 'Invalid secret', 26 | }); 27 | } 28 | 29 | const isValid = verifyWebhookSignature({ body, signature, secret }); 30 | if (!isValid) { 31 | return NextResponse.json({ 32 | status: 403, 33 | message: 'Signature is invalid', 34 | }); 35 | } 36 | 37 | try { 38 | console.log(`[Next.js] Revalidating ${TAGS.projects}`); 39 | revalidateTag(TAGS.projects); 40 | 41 | const slug = body?.data?.slug; 42 | 43 | console.log(`[Next.js] Revalidating ${TAGS.project(slug as string)}`); 44 | revalidateTag(TAGS.project(slug as string)); 45 | 46 | return NextResponse.json({ 47 | status: 200, 48 | message: 'Successfully revalidated project cache', 49 | now: Date.now(), 50 | }); 51 | } catch { 52 | return NextResponse.json({ 53 | status: 500, 54 | message: 'Error revalidating', 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/_components/navigation/navigation.tsx: -------------------------------------------------------------------------------- 1 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 2 | import { css, cx } from 'ds/css'; 3 | import { flex, hstack } from 'ds/patterns'; 4 | import { link } from 'ds/recipes'; 5 | import Link from 'next/link'; 6 | import ThemeToggle from './theme-toggle'; 7 | 8 | export const Navigation = () => { 9 | return ( 10 | 54 | ); 55 | }; 56 | 57 | const now = css({ 58 | lineHeight: 'tight', 59 | display: 'inline-block', 60 | color: 'transparent', 61 | background: 'linear-gradient(270deg,#8a8c93 50%,hsla(227,4%,56%,.6))', 62 | backgroundClip: 'text', 63 | _hover: { 64 | color: 'hsla(227,4%,56%,1)', 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /src/app/_styles/recipes/link.recipe.ts: -------------------------------------------------------------------------------- 1 | import { defineRecipe } from '@pandacss/dev'; 2 | 3 | export const linkRecipe = defineRecipe({ 4 | name: 'link', 5 | base: { 6 | '--activeColor': 'inherit', 7 | cursor: 'pointer', 8 | transitionDuration: 'default', 9 | transitionTimingFunction: 'default', 10 | transitionProperty: 'box-shadow, color', 11 | _focus: { 12 | outline: 'none', 13 | boxShadow: 'focus', 14 | }, 15 | }, 16 | variants: { 17 | color: { 18 | primary: { 19 | color: 'text1', 20 | '--activeColor': 'colors.slate11', 21 | _hover: { 22 | color: 'var(--activeColor)', 23 | }, 24 | _focus: { 25 | color: 'var(--activeColor)', 26 | }, 27 | }, 28 | secondary: { 29 | color: 'text2', 30 | '--activeColor': 'colors.slate9', 31 | _hover: { 32 | color: 'var(--activeColor)', 33 | }, 34 | _focus: { 35 | color: 'var(--activeColor)', 36 | }, 37 | }, 38 | accent: { 39 | color: 'text3', 40 | '--activeColor': 'colors.gold8', 41 | _hover: { 42 | color: 'var(--activeColor)', 43 | }, 44 | _focus: { 45 | color: 'var(--activeColor)', 46 | }, 47 | }, 48 | }, 49 | underline: { 50 | true: { 51 | pos: 'relative', 52 | _hover: { 53 | _after: { 54 | transform: 'translateY(0%)', 55 | opacity: 1, 56 | }, 57 | }, 58 | _after: { 59 | content: '""', 60 | pos: 'absolute', 61 | opacity: 0, 62 | w: 'full', 63 | h: 2, 64 | bgColor: 'var(--activeColor)', 65 | left: 0, 66 | bottom: -2, 67 | transitionDuration: 'default', 68 | transitionTimingFunction: 'default', 69 | transitionProperty: 'transform, opacity, color', 70 | transform: 'translateY(100%)', 71 | }, 72 | }, 73 | }, 74 | }, 75 | }); 76 | -------------------------------------------------------------------------------- /src/app/_styles/create-fluid-value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | More info: 3 | https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/ 4 | */ 5 | const DEFAULT_MIN_SCREEN = 360; 6 | const DEFAULT_MAX_SCREEN = 1040; 7 | 8 | const HTML_FONT_SIZE = 16; 9 | 10 | /** 11 | * It returns a CSS `clamp` function string that will fluidly 12 | * transition between a `minSize` and `maxSize` based on the screen size 13 | * @param minSize - Defines the lower limit of your fluid value. 14 | * @param maxSize - Defines the upper limit of your fluid value. 15 | * @param minScreenSize - When the fluid value should stop shrinking. 16 | * @param maxScreenSize - When the fluid value should stop growing. 17 | * @returns A string that is a css `clamp()` function 18 | */ 19 | export const createFluidValue = ( 20 | minSize: number, 21 | maxSize: number, 22 | minScreenSize: number = DEFAULT_MIN_SCREEN, 23 | maxScreenSize: number = DEFAULT_MAX_SCREEN, 24 | ) => { 25 | return `clamp(${pxToRem(minSize)}, ${getPreferredValue( 26 | minSize, 27 | maxSize, 28 | minScreenSize, 29 | maxScreenSize, 30 | )}, ${pxToRem(maxSize)})`; 31 | }; 32 | 33 | /** 34 | * Determines how fluid typography scales 35 | */ 36 | const getPreferredValue = ( 37 | minSize: number, 38 | maxSize: number, 39 | minScreenSize: number, 40 | maxScreenSize: number, 41 | ) => { 42 | const vwCalc = cleanNumber( 43 | (100 * (maxSize - minSize)) / (maxScreenSize - minScreenSize), 44 | ); 45 | const remCalc = cleanNumber( 46 | (minScreenSize * maxSize - maxScreenSize * minSize) / 47 | (minScreenSize - maxScreenSize), 48 | ); 49 | 50 | return `${vwCalc}vw + ${pxToRem(remCalc)}`; 51 | }; 52 | 53 | const pxToRem = (px: number | string) => 54 | `${cleanNumber(Number(px) / HTML_FONT_SIZE)}rem`; 55 | 56 | /** 57 | * It takes a number, adds a very small number to it, multiplies it by 100, rounds 58 | * it, and then divides it by 100 59 | * @param num - The number to be rounded. 60 | */ 61 | const cleanNumber = (num: number) => 62 | Math.round((num + Number.EPSILON) * 100) / 100; 63 | -------------------------------------------------------------------------------- /src/app/_components/prose-layout.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'ds/css'; 2 | import { stack, grid } from 'ds/patterns'; 3 | import * as React from 'react'; 4 | import Balancer from 'react-wrap-balancer'; 5 | import { BackToLink } from './back-to-link'; 6 | 7 | export const ProseLayout = ({ children }: { children: React.ReactNode }) => { 8 | return ( 9 |
18 | {children} 19 |
20 | ); 21 | }; 22 | 23 | export const ProseLayoutContent = ({ 24 | children, 25 | }: { 26 | children: React.ReactNode; 27 | }) => { 28 | return
{children}
; 29 | }; 30 | 31 | interface ProseLayoutHeaderProps { 32 | backTo: 33 | | { 34 | hasLink: true; 35 | href: string; 36 | content: string; 37 | } 38 | | { 39 | hasLink: false; 40 | }; 41 | headline?: string; 42 | description?: string | null; 43 | } 44 | 45 | export const ProseLayoutHeader = ({ 46 | backTo = { hasLink: false }, 47 | headline, 48 | description, 49 | children, 50 | }: React.PropsWithChildren) => { 51 | return ( 52 |
60 |
61 | {backTo.hasLink ? ( 62 | {backTo.content} 63 | ) : null} 64 |
65 | {headline != null && ( 66 |

67 | {headline} 68 |

69 | )} 70 | {description != null && ( 71 |

79 | {description} 80 |

81 | )} 82 | {children} 83 |
84 |
85 |
86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /src/app/_theme/global-css.ts: -------------------------------------------------------------------------------- 1 | import { defineGlobalStyles } from '@pandacss/dev'; 2 | 3 | // Code syntax 4 | export const globalCss = defineGlobalStyles({ 5 | html: { 6 | fontFamily: 'primary', 7 | boxSizing: 'border-box', 8 | WebkitFontSmoothing: 'antialiased', 9 | textRendering: 'optimizeLegibility', 10 | backgroundColor: 'uiBg', 11 | color: 'text1', 12 | }, 13 | button: { 14 | cursor: 'pointer', 15 | transitionDuration: 'default', 16 | transitionTimingFunction: 'default', 17 | transitionProperty: 'box-shadow', 18 | _focus: { 19 | outline: 'none', 20 | boxShadow: 'focus', 21 | }, 22 | }, 23 | a: { 24 | transitionDuration: 'default', 25 | transitionTimingFunction: 'default', 26 | transitionProperty: 'box-shadow', 27 | _focus: { 28 | outline: 'none', 29 | boxShadow: 'focus', 30 | }, 31 | }, 32 | 'html.dark-theme div[data-theme="light"], html.dark-theme pre[data-theme="light"], html.dark-theme code[data-theme="light"], html.light-theme div[data-theme="dark"], html.light-theme pre[data-theme="dark"], html.light-theme code[data-theme="dark"]': 33 | { 34 | display: 'none', 35 | }, 36 | '[data-rehype-pretty-code-title]': { 37 | py: 's', 38 | px: 'm', 39 | border: '1px solid', 40 | borderColor: 'slate8', 41 | borderBottomLeftRadius: 0, 42 | borderBottomRightRadius: 0, 43 | }, 44 | 'pre[data-language], :not(pre)>code': { 45 | whiteSpace: 'pre', 46 | bgColor: 'surface1', 47 | fontSize: `calc(var(--font-sizes-1) * 0.95)`, 48 | webkitTextSizeAdjust: 'none', 49 | fontFamily: 'mono', 50 | }, 51 | ':not([data-rehype-pretty-code-title]) + pre[data-language]': { 52 | borderTopLeftRadius: 0, 53 | borderTopRightRadius: 0, 54 | borderTop: 0, 55 | }, 56 | 'div[data-rehype-pretty-code-fragment]': { 57 | code: { 58 | display: 'grid', 59 | }, 60 | pre: { 61 | overflowX: 'auto', 62 | py: 'm', 63 | }, 64 | '[data-line]': { 65 | borderLeft: '4px solid', 66 | borderLeftColor: 'transparent', 67 | px: 'm', 68 | fontFamily: 'mono', 69 | }, 70 | }, 71 | 'code[data-line-numbers]': { 72 | counterReset: 'line', 73 | '[data-line]::before': { 74 | counterIncrement: 'line', 75 | content: 'counter(line)', 76 | display: 'inline-block', 77 | textAlign: 'right', 78 | mr: 'm', 79 | opacity: 0.5, 80 | }, 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /src/app/now/[update]/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ProseLayout, 3 | ProseLayoutContent, 4 | ProseLayoutHeader, 5 | } from '@/app/_components/prose-layout'; 6 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 7 | import { 8 | parseDateToLongDateString, 9 | parseDateToString, 10 | } from '@/app/_utils/helpers/date.helpers'; 11 | import { allUpdates } from 'contentlayer/generated'; 12 | import { css } from 'ds/css'; 13 | import { Metadata } from 'next'; 14 | import { notFound } from 'next/navigation'; 15 | import { UpdatePageMDX } from './update-page-mdx'; 16 | 17 | export const generateStaticParams = () => { 18 | return allUpdates.map((update) => ({ update: update.slug })); 19 | }; 20 | 21 | export const generateMetadata = ({ 22 | params, 23 | }: { 24 | params: { update: string }; 25 | }): Metadata => { 26 | const update = allUpdates.find((update) => update.slug === params.update); 27 | 28 | if (!update) { 29 | return {}; 30 | } 31 | 32 | const { date } = update; 33 | 34 | const fancyDate = parseDateToLongDateString(date); 35 | const headline = `Now: ${parseDateToString(date)}`; 36 | 37 | const url = new URL(`${PATHS.base}${PATHS.now}/${update.slug}`); 38 | const title = headline; 39 | const description = `A snapshot of my life—${fancyDate}.`; 40 | 41 | return { 42 | title, 43 | description, 44 | openGraph: { 45 | url, 46 | type: 'article', 47 | authors: ['https://twitter.com/jennings_hunter'], 48 | locale: 'en_US', 49 | title, 50 | description, 51 | publishedTime: date, 52 | }, 53 | }; 54 | }; 55 | 56 | export default function Update({ params }: { params: { update: string } }) { 57 | const update = allUpdates.find((update) => update.slug === params.update); 58 | 59 | if (!update) { 60 | return notFound(); 61 | } 62 | 63 | const { body, date } = update; 64 | const fancyDate = parseDateToString(date); 65 | return ( 66 | 67 | 74 | 75 | 76 | 77 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './_styles/global.css'; 2 | import { basierCircle, untitledSerif, jetbrainsMono } from './_styles/fonts'; 3 | import { Providers } from './providers'; 4 | import { Metadata } from 'next'; 5 | import { css } from 'ds/css'; 6 | import { Footer } from './_components/footer/footer'; 7 | import { Analytics } from '@vercel/analytics/react'; 8 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 9 | import { Navigation } from './_components/navigation/navigation'; 10 | 11 | const title = 'Portfolio | Hunter Jennings'; 12 | const description = 13 | 'Hunter Jennings is a Frontend Developer based in Richmond, VA'; 14 | 15 | export const metadata: Metadata = { 16 | metadataBase: new URL(PATHS.base), 17 | robots: 'follow, index', 18 | title: { 19 | template: '%s | Hunter Jennings', 20 | default: title, 21 | }, 22 | description, 23 | openGraph: { 24 | url: PATHS.base, 25 | type: 'website', 26 | locale: 'en_US', 27 | title, 28 | description, 29 | }, 30 | twitter: { 31 | creator: '@jennings_hunter', 32 | site: '@jennings_hunter', 33 | card: 'summary_large_image', 34 | }, 35 | }; 36 | 37 | interface RootLayoutProps { 38 | children: React.ReactNode; 39 | } 40 | export default function RootLayout({ children }: RootLayoutProps) { 41 | return ( 42 | 47 | 48 | 49 |
50 |
51 | 52 |
{children}
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | ); 61 | } 62 | 63 | const container = css({ 64 | w: 'full', 65 | bgColor: 'uiBg', 66 | h: 'full', 67 | minH: 'screenH', 68 | display: 'flex', 69 | alignItems: 'center', 70 | flexDir: 'column', 71 | px: 's', 72 | }); 73 | 74 | const wrapper = css({ 75 | gridTemplateAreas: `'nav' 76 | 'main' 77 | 'footer'`, 78 | gridTemplateColumns: '1fr', 79 | gridTemplateRows: 'auto 1fr auto', 80 | display: 'grid', 81 | maxW: 'channel', 82 | w: 'full', 83 | h: 'full', 84 | minH: 'screenH', 85 | pos: 'relative', 86 | zIndex: 'init', 87 | }); 88 | 89 | const main = css({ 90 | w: 'full', 91 | zIndex: 1, 92 | gridArea: 'main', 93 | }); 94 | -------------------------------------------------------------------------------- /contentlayer.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | import { defineDocumentType, makeSource } from 'contentlayer/source-files'; 4 | import readingTime from 'reading-time'; 5 | import rehypePrettyCode, { Options } from 'rehype-pretty-code'; 6 | 7 | const Update = defineDocumentType(() => ({ 8 | name: 'Update', 9 | filePathPattern: 'updates/*.mdx', 10 | contentType: 'mdx', 11 | fields: { 12 | date: { 13 | type: 'date', 14 | description: 'The date the page was published', 15 | required: true, 16 | }, 17 | }, 18 | computedFields: { 19 | slug: { 20 | type: 'string', 21 | resolve: ({ _raw }) => { 22 | return _raw.sourceFileName.replace(/\.mdx?$/, ''); 23 | }, 24 | }, 25 | }, 26 | })); 27 | 28 | const Writing = defineDocumentType(() => ({ 29 | name: 'Writing', 30 | filePathPattern: 'writings/*.mdx', 31 | contentType: 'mdx', 32 | fields: { 33 | title: { 34 | type: 'string', 35 | description: 'The title of the page', 36 | required: true, 37 | }, 38 | description: { 39 | type: 'string', 40 | description: 'The description of the page', 41 | required: true, 42 | }, 43 | date: { 44 | type: 'date', 45 | description: 'The date the page was published', 46 | required: true, 47 | }, 48 | featured: { 49 | type: 'boolean', 50 | description: 'Whether the page should be featured', 51 | required: true, 52 | }, 53 | status: { 54 | type: 'enum', 55 | options: ['draft', 'published'], 56 | required: true, 57 | }, 58 | }, 59 | computedFields: { 60 | slug: { 61 | type: 'string', 62 | resolve: ({ _raw }) => { 63 | return _raw.sourceFileName.replace(/\.mdx?$/, ''); 64 | }, 65 | }, 66 | readingTime: { 67 | type: 'string', 68 | resolve: (doc) => readingTime(doc.body.raw, { wordsPerMinute: 275 }).text, 69 | }, 70 | }, 71 | })); 72 | 73 | const rehypePrettyCodeOptions: Partial = { 74 | theme: { 75 | dark: 'github-dark', 76 | light: 'github-light', 77 | }, 78 | tokensMap: { 79 | fn: 'entity.name.function', 80 | objKey: 'meta.object-literal.key', 81 | }, 82 | }; 83 | 84 | export default makeSource({ 85 | contentDirPath: './src/data', 86 | date: { 87 | timezone: 'America/New_York', 88 | }, 89 | documentTypes: [Writing, Update], 90 | mdx: { 91 | rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions]], 92 | }, 93 | }); 94 | -------------------------------------------------------------------------------- /src/app/now/page.tsx: -------------------------------------------------------------------------------- 1 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 2 | import { 3 | parseDateToString, 4 | sortArrayByDateDesc, 5 | } from '@/app/_utils/helpers/date.helpers'; 6 | import { Update, allUpdates } from 'contentlayer/generated'; 7 | import { Metadata } from 'next'; 8 | import { 9 | ProseLayout, 10 | ProseLayoutContent, 11 | ProseLayoutHeader, 12 | } from '../_components/prose-layout'; 13 | import { flex } from 'ds/patterns'; 14 | import Link from 'next/link'; 15 | import { link } from 'ds/recipes'; 16 | import { css } from 'ds/css'; 17 | import { UpdatePageMDX } from './[update]/update-page-mdx'; 18 | 19 | const title = 'Now'; 20 | const description = 'A snapshot of my life via short updates.'; 21 | const url = new URL(`${PATHS.base}${PATHS.now}`); 22 | 23 | export const metadata: Metadata = { 24 | title, 25 | description, 26 | robots: 'follow, index', 27 | openGraph: { 28 | url, 29 | type: 'website', 30 | locale: 'en_US', 31 | title, 32 | description, 33 | }, 34 | }; 35 | 36 | export default function Now() { 37 | const updates = sortArrayByDateDesc(allUpdates); 38 | 39 | return ( 40 | 41 | 50 | 51 |
52 | {updates.map((update) => { 53 | return ; 54 | })} 55 |
56 |
57 |
58 | ); 59 | } 60 | 61 | interface UpdateProps { 62 | update: Update; 63 | } 64 | const Update = ({ update }: UpdateProps) => { 65 | const { body, date } = update; 66 | const fancyDate = parseDateToString(date); 67 | 68 | return ( 69 |
80 | 81 | 85 | 96 | 97 |
98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /src/app/_components/footer/weather.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | OpenWeatherResponse, 3 | OpenWeatherResponseZod, 4 | WeatherData, 5 | } from '@/app/_utils/types/open-weather'; 6 | import { css } from 'ds/css'; 7 | import { circle, grid } from 'ds/patterns'; 8 | 9 | export const Weather = async () => { 10 | const { icon, description, temp } = await getWeather(); 11 | 12 | return ( 13 | <> 14 | 15 | 22 | {tempText(temp)}°F 23 | 24 | 25 | ); 26 | }; 27 | 28 | const API_URL = `https://api.openweathermap.org/data/2.5/weather?id=4781708&units=imperial&appid=${process.env.WEATHER_API_KEY}`; 29 | const getWeather = async () => { 30 | const response = await fetch(API_URL, { 31 | next: { revalidate: 60 }, 32 | }); 33 | const data = (await response.json()) as OpenWeatherResponse; 34 | const checked = OpenWeatherResponseZod.parse(data); 35 | return mapResponseData(checked); 36 | }; 37 | 38 | const mapResponseData = (data: OpenWeatherResponse): WeatherData => { 39 | const weather = data.weather[0]; 40 | return { 41 | temp: data.main.temp, 42 | description: weather ? weather.description : undefined, 43 | icon: weather ? weather.icon : undefined, 44 | }; 45 | }; 46 | 47 | const WeatherIcon = ({ description, icon }: Omit) => { 48 | return icon != null ? ( 49 | // eslint-disable-next-line @next/next/no-img-element 50 | {description 58 | ) : ( 59 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | const tempText = (temp: number | undefined) => { 70 | return temp === undefined ? 'XX' : Math.round(temp).toString(); 71 | }; 72 | 73 | export const WeatherError = () => { 74 | return ( 75 | 83 | weather data errored 84 | 85 | ); 86 | }; 87 | 88 | export const WeatherLoading = () => { 89 | return ( 90 | 97 | loading... 98 | 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /src/app/_utils/og-template.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from 'next/server'; 2 | 3 | export const size = { 4 | width: 1200, 5 | height: 630, 6 | }; 7 | export const contentType = 'image/png'; 8 | 9 | const getSansSerifFont = async () => { 10 | const res = await fetch( 11 | new URL('../_assets/fonts/basiercircle-regular.otf', import.meta.url), 12 | ); 13 | const data = await res.arrayBuffer(); 14 | return data; 15 | }; 16 | 17 | const getSerifFont = async () => { 18 | const res = await fetch( 19 | new URL( 20 | '../_assets/fonts/untitled-serif-regular-italic.otf', 21 | import.meta.url, 22 | ), 23 | ); 24 | const data = await res.arrayBuffer(); 25 | return data; 26 | }; 27 | 28 | const DEFAULT_TITLE = 'Hunter Jennings'; 29 | const DEFAULT_SUB = 30 | 'Frontend ui engineer interested in design systems, component architectures, and React.'; 31 | 32 | export const ogTemplate = async ({ 33 | title, 34 | sub, 35 | }: { 36 | title?: string; 37 | sub?: string; 38 | } = {}) => { 39 | return new ImageResponse( 40 | ( 41 |
45 | 55 | H—J 56 | 57 |
63 |
64 |

74 | {title ?? DEFAULT_TITLE} 75 |

76 |
77 |
78 |

87 | {sub ?? DEFAULT_SUB} 88 |

89 |
90 |
91 |
92 | ), 93 | { 94 | ...size, 95 | fonts: [ 96 | { 97 | name: 'Untitled', 98 | data: await getSerifFont(), 99 | weight: 400, 100 | style: 'italic', 101 | }, 102 | { 103 | name: 'Basier', 104 | data: await getSansSerifFont(), 105 | weight: 400, 106 | style: 'normal', 107 | }, 108 | ], 109 | }, 110 | ); 111 | }; 112 | -------------------------------------------------------------------------------- /src/app/_components/footer/footer.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'ds/css'; 2 | import { flex, hstack, stack } from 'ds/patterns'; 3 | import * as React from 'react'; 4 | import { Weather, WeatherError, WeatherLoading } from './weather'; 5 | import { ErrorBoundary } from 'react-error-boundary'; 6 | import dynamic from 'next/dynamic'; 7 | 8 | const Time = dynamic(() => import('./time'), { 9 | ssr: false, 10 | loading: () => ( 11 | 19 | 00:00 XX 20 | 21 | ), 22 | }); 23 | 24 | export const Footer = () => { 25 | return ( 26 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
46 |
58 |
65 | }> 66 | }> 67 | 68 | 69 | 70 |
71 | 81 | Always pushing. 82 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | const dot = css({ 89 | w: 2, 90 | h: 2, 91 | rounded: 'round', 92 | bgColor: 'slate11', 93 | }); 94 | 95 | const footerWrapper = css({ 96 | display: 'grid', 97 | gridTemplateAreas: `"a b" 98 | "c c"`, 99 | gridTemplateColumns: '1fr 1fr', 100 | alignItems: 'end', 101 | justifyContent: 'start', 102 | columnGap: 's', 103 | rowGap: { base: 'xl', bp1: 's' }, 104 | w: 'full', 105 | bp1: { 106 | gridTemplateAreas: `"a b c"`, 107 | gridTemplateColumns: 'repeat(3, 1fr)', 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /src/app/_theme/tokens.ts: -------------------------------------------------------------------------------- 1 | import { createFluidValue } from '@/app/_styles/create-fluid-value'; 2 | import { defineTokens } from '@pandacss/dev'; 3 | 4 | const getConfigFluidValue = (minSize: number, maxSize: number) => 5 | createFluidValue(minSize, maxSize, 360, 1024); 6 | 7 | export const tokens = defineTokens({ 8 | colors: { 9 | uiBg: { 10 | value: '{colors.slate1}', 11 | }, 12 | surface1: { 13 | value: '{colors.slate3}', 14 | }, 15 | surface2: { 16 | value: '{colors.slate12}', 17 | }, 18 | text2: { 19 | value: '{colors.slate11}', 20 | }, 21 | text3: { 22 | value: '{colors.gold9}', 23 | }, 24 | text4: { 25 | value: '{colors.slate1}', 26 | }, 27 | }, 28 | spacing: { 29 | none: { value: 0 }, 30 | '3xs': { value: getConfigFluidValue(4, 5) }, 31 | '2xs': { value: getConfigFluidValue(8, 10) }, 32 | xs: { value: getConfigFluidValue(12, 15) }, 33 | s: { value: getConfigFluidValue(16, 20) }, 34 | m: { value: getConfigFluidValue(24, 30) }, 35 | l: { value: getConfigFluidValue(32, 40) }, 36 | xl: { value: getConfigFluidValue(48, 60) }, 37 | '2xl': { value: getConfigFluidValue(64, 80) }, 38 | '3xl': { value: getConfigFluidValue(96, 120) }, 39 | }, 40 | sizes: { 41 | prose: { value: '60ch' }, 42 | full: { value: '100%' }, 43 | channel: { value: '700px' }, 44 | screenW: { value: '100vw' }, 45 | screenH: { value: '100vh' }, 46 | desktop: { value: '1440px' }, 47 | }, 48 | radii: { 49 | none: { value: '0' }, 50 | round: { value: '50%' }, 51 | pill: { value: '9999px' }, 52 | card: { value: '15px' }, 53 | }, 54 | fonts: { 55 | primary: { value: 'var(--font-sans)' }, 56 | serif: { value: 'var(--font-serif)' }, 57 | mono: { value: 'var(--font-mono)' }, 58 | }, 59 | fontWeights: { 60 | bold: { value: '700' }, 61 | regular: { value: '400' }, 62 | }, 63 | fontSizes: { 64 | '1': { value: getConfigFluidValue(13, 16) }, 65 | '2': { value: getConfigFluidValue(16, 20) }, 66 | '3': { value: getConfigFluidValue(20, 25) }, 67 | '4': { value: getConfigFluidValue(25, 31) }, 68 | '5': { value: getConfigFluidValue(31, 39) }, 69 | '6': { value: getConfigFluidValue(39, 49) }, 70 | '7': { value: getConfigFluidValue(49, 61) }, 71 | '8': { value: getConfigFluidValue(61, 76) }, 72 | }, 73 | lineHeights: { 74 | tight: { value: '1' }, 75 | body: { value: '1.5' }, 76 | loose: { value: '2' }, 77 | }, 78 | zIndex: { 79 | under: { value: -1 }, 80 | over: { value: 1 }, 81 | init: { value: 0 }, 82 | nuclear: { value: 9999 }, 83 | }, 84 | shadows: { 85 | focus: { value: '0 0 0 3px {colors.gold6}' }, 86 | }, 87 | durations: { 88 | default: { 89 | value: '225ms', 90 | }, 91 | }, 92 | easings: { 93 | default: { 94 | value: 'cubic-bezier(0.4, 0, 0.2, 1)', 95 | }, 96 | }, 97 | animations: { 98 | skeleton: { 99 | value: 'skeleton 8s ease-in-out infinite', 100 | }, 101 | }, 102 | }); 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portfolio-v3", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "husky install && panda codegen", 7 | "gen": "graphql-codegen --require dotenv/config --config codegen.ts dotenv_config_path=.env.local", 8 | "dev": "rm -rf .next && pnpm run gen && next dev", 9 | "build": "pnpm run gen && next build && next-sitemap", 10 | "analyze": "cross-env ANALYZE=true next build", 11 | "typecheck": "tsc --noEmit", 12 | "start": "next start", 13 | "lint": "next lint", 14 | "lint:staged": "lint-staged" 15 | }, 16 | "lint-staged": { 17 | "./src/**/*.{js,jsx,ts,tsx}": [ 18 | "eslint --fix", 19 | "prettier --write" 20 | ] 21 | }, 22 | "dependencies": { 23 | "@giscus/react": "^2.3.0", 24 | "@graphcms/rich-text-react-renderer": "^0.6.1", 25 | "@graphql-typed-document-node/core": "^3.2.0", 26 | "@hygraph/utils": "^1.2.1", 27 | "@radix-ui/colors": "^1.0.0", 28 | "@radix-ui/react-aspect-ratio": "^1.0.3", 29 | "@radix-ui/react-scroll-area": "^1.0.4", 30 | "@radix-ui/react-separator": "^1.0.3", 31 | "@radix-ui/react-visually-hidden": "^1.0.3", 32 | "@vercel/analytics": "^1.0.1", 33 | "@vercel/og": "^0.5.8", 34 | "clsx": "^1.2.1", 35 | "contentlayer": "^0.3.4", 36 | "date-fns": "^2.30.0", 37 | "framer-motion": "^10.12.18", 38 | "graphql": "^16.7.1", 39 | "graphql-request": "^6.1.0", 40 | "graphql-tag": "^2.12.6", 41 | "next": "^13.4.8", 42 | "next-contentlayer": "^0.3.4", 43 | "next-sitemap": "^4.1.8", 44 | "next-themes": "^0.2.1", 45 | "react": "18.2.0", 46 | "react-dom": "18.2.0", 47 | "react-error-boundary": "^4.0.10", 48 | "react-wrap-balancer": "^1.0.0", 49 | "reading-time": "^1.5.0", 50 | "rehype-pretty-code": "^0.10.0", 51 | "sharp": "^0.32.1", 52 | "zod": "^3.21.4" 53 | }, 54 | "devDependencies": { 55 | "@graphcms/rich-text-types": "^0.5.0", 56 | "@graphql-codegen/add": "^5.0.0", 57 | "@graphql-codegen/cli": "^3.3.1", 58 | "@graphql-codegen/schema-ast": "^4.0.0", 59 | "@graphql-codegen/typescript": "^4.0.1", 60 | "@graphql-codegen/typescript-graphql-request": "^5.0.0", 61 | "@graphql-codegen/typescript-operations": "^4.0.1", 62 | "@next/bundle-analyzer": "^13.4.8", 63 | "@pandacss/dev": "^0.5.1", 64 | "@types/body-scroll-lock": "^3.1.0", 65 | "@types/node": "^20.3.3", 66 | "@types/react": "18.2.14", 67 | "@typescript-eslint/eslint-plugin": "^5.61.0", 68 | "@typescript-eslint/parser": "^5.61.0", 69 | "concurrently": "^8.2.0", 70 | "cross-env": "^7.0.3", 71 | "dotenv": "^16.3.1", 72 | "encoding": "^0.1.13", 73 | "eslint": "8.44.0", 74 | "eslint-config-next": "13.4.8", 75 | "eslint-config-prettier": "^8.8.0", 76 | "husky": "^8.0.3", 77 | "lint-staged": "^13.2.3", 78 | "postcss": "^8.4.24", 79 | "postcss-easing-gradients": "^3.0.1", 80 | "postcss-flexbugs-fixes": "^5.0.2", 81 | "postcss-preset-env": "^9.0.0", 82 | "prettier": "^2.8.8", 83 | "prettier-plugin-organize-imports": "^3.2.2", 84 | "start-server-and-test": "^2.0.0", 85 | "ts-node": "^10.9.1", 86 | "typescript": "^5.1.6" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/writing/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { GiscusComments } from '@/app/_components/giscus-comments'; 2 | import { 3 | ProseLayout, 4 | ProseLayoutContent, 5 | ProseLayoutHeader, 6 | } from '@/app/_components/prose-layout'; 7 | import { Separator } from '@/app/_components/separator'; 8 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 9 | import { parseDateToString } from '@/app/_utils/helpers/date.helpers'; 10 | import { allWritings } from 'contentlayer/generated'; 11 | import { css } from 'ds/css'; 12 | import { hstack, stack } from 'ds/patterns'; 13 | import { Metadata } from 'next'; 14 | import { notFound } from 'next/navigation'; 15 | import { SlugPageMDX } from './slug-page-mdx'; 16 | 17 | export const generateStaticParams = () => { 18 | return allWritings.map((writing) => ({ slug: writing.slug })); 19 | }; 20 | 21 | export const generateMetadata = ({ 22 | params, 23 | }: { 24 | params: { slug: string }; 25 | }): Metadata => { 26 | const writing = allWritings.find((writing) => writing.slug === params.slug); 27 | 28 | if (!writing) { 29 | return {}; 30 | } 31 | 32 | const { title, description, date } = writing; 33 | 34 | const url = new URL(`${PATHS.base}${PATHS.writing}/${writing.slug}`); 35 | 36 | return { 37 | title, 38 | description, 39 | openGraph: { 40 | url, 41 | type: 'article', 42 | authors: ['https://twitter.com/jennings_hunter'], 43 | locale: 'en_US', 44 | title, 45 | description, 46 | publishedTime: date, 47 | }, 48 | }; 49 | }; 50 | 51 | export default function Writing({ params }: { params: { slug: string } }) { 52 | const writing = allWritings.find((writing) => writing.slug === params.slug); 53 | 54 | if (!writing) { 55 | return notFound(); 56 | } 57 | 58 | const { title, description, date, body, readingTime } = writing; 59 | 60 | return ( 61 | 62 | 71 |
76 |
77 | 78 | Published 79 | 80 | 81 | {parseDateToString(date)} 82 | 83 |
84 |
85 | 86 | Reading Time 87 | 88 | {readingTime} 89 |
90 |
91 |
92 | 93 | 94 |
95 | 96 |
97 | 98 |
99 |
100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/app/_utils/constants/mdx.constants.tsx: -------------------------------------------------------------------------------- 1 | import { Separator } from '@/app/_components/separator'; 2 | import { blockquote } from '@/app/_styles/blockquote'; 3 | import { 4 | inlineElementReset, 5 | listItem, 6 | orderedList, 7 | unorderedList, 8 | } from '@/app/_styles/rich-text'; 9 | import { css, cx } from 'ds/css'; 10 | import { link } from 'ds/recipes'; 11 | import Link from 'next/link'; 12 | 13 | export const MDX_ELEMENTS = { 14 | h1: (props: any) => ( 15 |

25 | ), 26 | h2: (props: any) => ( 27 |

38 | ), 39 | h3: (props: any) => ( 40 |

50 | ), 51 | h4: (props: any) => ( 52 |

62 | ), 63 | h5: (props: any) => ( 64 |

74 | ), 75 | h6: (props: any) => ( 76 |
86 | ), 87 | p: (props: any) => ( 88 |

95 | ), 96 | strong: (props: any) => ( 97 | 101 | ), 102 | em: (props: any) => ( 103 | 110 | ), 111 | a: (props: any) => ( 112 | 119 | ), 120 | ul: (props: any) =>

    , 121 | ol: (props: any) =>
      , 122 | li: (props: any) => ( 123 |
    1. 127 | ), 128 | hr: (props: any) => , 129 | blockquote: (props: any) =>
      , 130 | pre: (props: any) => ( 131 |
      139 |   ),
      140 | } as const;
      141 | 
      
      
      --------------------------------------------------------------------------------
      /src/app/_components/project-card.tsx:
      --------------------------------------------------------------------------------
        1 | import { HygraphImageWithLoader } from '@/app/_components/hygraph-image-with-loader';
        2 | import { PATHS } from '@/app/_utils/constants/paths.constants';
        3 | import { parseTagsToString } from '@/app/_utils/helpers/string.helpers';
        4 | import { css, cx } from 'ds/css';
        5 | import { linkBox, linkOverlay, stack } from 'ds/patterns';
        6 | import Link from 'next/link';
        7 | import { AspectRatioRoot } from './aspect-ratio';
        8 | import { skeleton } from '../_styles/skeleton';
        9 | import { ProjectInfoFragment } from '@/graphql/generated/cms.generated';
       10 | 
       11 | interface ProjectCardProps {
       12 |   project: ProjectInfoFragment;
       13 |   sizes?: string;
       14 | }
       15 | export const ProjectCard = ({ project, sizes = '100vw' }: ProjectCardProps) => {
       16 |   const { featureMediaNarrow, slug, category, name } = project;
       17 |   const tagsString = parseTagsToString(category);
       18 |   const src = featureMediaNarrow.url;
       19 | 
       20 |   return (
       21 |     
      22 |
      23 |
      31 | 32 | 41 | 42 |
      43 |
      44 | 49 |

      57 | {name} 58 |

      59 | 60 |

      67 | {tagsString} 68 |

      69 |
      70 |
      71 |
      72 | ); 73 | }; 74 | 75 | export const ProjectCardLoadingUI = () => { 76 | return ( 77 |
      78 |
      79 | 80 |
      81 |
      82 | 96 | H 97 | 98 | 111 | J 112 | 113 |
      114 |
      115 | ); 116 | }; 117 | -------------------------------------------------------------------------------- /src/app/work/[project]/_components/more-projects.tsx: -------------------------------------------------------------------------------- 1 | import { getProjects } from '@/app/_utils/helpers/projects.helpers'; 2 | import ArrowLeftIcon from '@/app/_components/icons/ArrowLeftIcon'; 3 | import ArrowRightIcon from '@/app/_components/icons/ArrowRightIcon'; 4 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 5 | import { css, cx } from 'ds/css'; 6 | import { flex, hstack, linkBox, linkOverlay } from 'ds/patterns'; 7 | import { link } from 'ds/recipes'; 8 | import { token } from 'ds/tokens'; 9 | import Link from 'next/link'; 10 | 11 | export const MoreProjects = async ({ current }: { current: string }) => { 12 | const { projects } = await getProjects(); 13 | const currentProjectIndex = projects.findIndex((p) => p.slug === current); 14 | 15 | const [previous, next] = prevNextProjectData(currentProjectIndex, projects); 16 | 17 | const shouldCenter = isFalsy(previous) || isFalsy(next); 18 | 19 | return ( 20 |
      28 | {previous ? ( 29 | 36 | ) : null} 37 | {next ? ( 38 | 45 | ) : null} 46 |
      47 | ); 48 | }; 49 | 50 | interface LinkProps { 51 | slug: string; 52 | name: string; 53 | arrowDirection: 'left' | 'right'; 54 | alignment: 'left' | 'right' | 'center'; 55 | } 56 | 57 | const ProjectLink = ({ 58 | slug, 59 | name, 60 | arrowDirection, 61 | alignment, 62 | }: LinkProps): JSX.Element => { 63 | const justify = ALIGNMENT_LOOKUP[alignment]; 64 | return ( 65 |
      76 |
      77 |
      83 | {arrowDirection === 'left' ? ( 84 | 89 | ) : null} 90 | 94 | 101 | {name} 102 | 103 | 104 | {arrowDirection === 'right' ? ( 105 | 110 | ) : null} 111 |
      112 |
      113 |
      114 | ); 115 | }; 116 | 117 | function prevNextProjectData( 118 | idx: number, 119 | projects: T, 120 | ): [T[number] | null, T[number] | null] { 121 | const previousProject = idx > 0 ? projects[idx - 1] : null; 122 | const nextProject = idx < projects.length - 1 ? projects[idx + 1] : null; 123 | return [previousProject, nextProject]; 124 | } 125 | 126 | const isFalsy = (val: any) => { 127 | return val == null; 128 | }; 129 | 130 | const ALIGNMENT_LOOKUP = { 131 | left: 'flex-start', 132 | right: 'flex-end', 133 | center: 'center', 134 | } as const; 135 | -------------------------------------------------------------------------------- /src/data/writings/website-redesign-2022.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Website Redesign 2022 3 | description: Starting the new year off fresh. A new look and new tech. 4 | date: 2022-01-17 5 | featured: false 6 | status: published 7 | --- 8 | 9 | It's exciting to start something new. It gives us the opportunity to clear out 10 | the cache in our heads and put a new thing out into the world. No tech debt, no 11 | stretching along the poor ideas of our previous selves. For me, I wanted to 12 | switch it up and make something new that reflected where I'm at _today_, my 13 | skill sets, interests, etc. A place where I can show what I care about 14 | [now](/now). Often these sites tend to grow stale. 15 | 16 | ## How it started 17 | 18 | This is the third version of my personal site. Well, it's kind of v3.5, 19 | actually. I nearly finished the last iteration a few months ago. 20 | 21 | 22 | 23 | Screenshot of the details page for my first iteration of this portfolio site 31 | 32 | 33 | 34 | 35 | 36 | Screenshot of the details page for my first iteration of this portfolio site 44 | 45 | 46 | 47 | Nearly 80% of it was fully designed and implemented until I decided to scrap it 48 | all, leaving only the tech stack choices behind. It's always that last bit. It 49 | wasn't giving me the feel I wanted. I noticed that I was making the _exact_ same 50 | mistakes that I did with previous iterations. I'd end up getting lost in the 51 | design details that don't have any meaning. They don't actually get you to 52 | finishing the goal you set out to complete in the first place. 53 | 54 | I couldn't _already_ be sick of this thing. That ended up being enough of a 55 | signal that I needed to suck it up and start over. 56 | 57 | I didn't want to overthink it. Simplicity, consistency, maintainability were my 58 | top priorities—especially 59 | [maintainability](https://brianlovin.com/writing/reasons-you-arent-updating-your-personal-site#too-hard-to-add-and-edit-content). 60 | I didn't need to reinvent the wheel, and it most definitely should not feel like 61 | a chore to update and edit or add new content. We all know where that leads 62 | _sigh_...back to Figma. 63 | 64 | ## How it's going 65 | 66 | This is what I came up with after a few weeks of nights and weekends. The design 67 | stands on the many shoulders of those I admire and look up to in my field of UI 68 | development. These people inspired me to ditch all of the noise and reject the 69 | traps found in over-designing. Instead, favoring an approach of simplicity, 70 | which strips away all the excuses one could make, and actually showcase what 71 | really matters—the content. If you see a design idea you like, go ahead and 72 | [steal it](https://austinkleon.com/steal/)! 73 | 74 | Shout-out to [Paco Coursey](https://paco.me/), 75 | [Carl Barenbrug](https://cmhb.de/), [Rauno Freiberg](https://rauno.me/), 76 | [Pedro Duarte](https://ped.ro/), [Lee Robinson](https://leerob.io/), 77 | [Tim Chang](https://timcchang.com/), [Jenna Smith](https://jjenzz.com/), and 78 | others—there's a lot of them—for all of the inspiration. 79 | 80 | Now that I'm _"done"_, I'm hoping I can start the new year off strong and share 81 | more about myself, my process, things I'm interested in. I've been on the 82 | receiving end of so many generous people taking the time to write insightful and 83 | useful posts about technology that I'm learning about. Hopefully I can give back 84 | a bit where I can. 85 | 86 | ## Tech stack for nerds 87 | 88 | Ah, the most important part, the tech stack: 89 | 90 | - [TypeScript](https://www.typescriptlang.org/) 91 | - [React](https://reactjs.org/) 92 | - [Next.js](https://nextjs.org/) 93 | - We use this framework heavily at my [job](https://www.elegantseagulls.com/) 94 | and I'm extremely bullish about it's future. The team consistently raises 95 | that bar with each release. 96 | - [Vanilla Extract](https://vanilla-extract.style/) 97 | - [Radix](https://www.radix-ui.com/) 98 | - Un-styled utility components for building accessible UIs. 99 | - [MDX](https://mdxjs.com/) 100 | - How I'm able to write this blog. 101 | - [Framer Motion](https://www.framer.com/motion/) 102 | - Declarative animations for React. 103 | 104 | The site is [open source on GitHub](https://github.com/h-jennings/portfolio-v3), 105 | feel free to check it out and reach out if you have any questions about it. My 106 | DMs [are open](https://twitter.com/jennings_hunter). 107 | -------------------------------------------------------------------------------- /src/app/work/page.tsx: -------------------------------------------------------------------------------- 1 | import { RichText } from '@graphcms/rich-text-react-renderer'; 2 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 3 | import { grid, linkBox, linkOverlay, stack } from 'ds/patterns'; 4 | import { Metadata } from 'next'; 5 | import { BackToLink } from '../_components/back-to-link'; 6 | import { css } from 'ds/css'; 7 | import Link from 'next/link'; 8 | import { token } from 'ds/tokens'; 9 | import { RichTextContent } from '@graphcms/rich-text-types'; 10 | import { ProjectCard } from '../_components/project-card'; 11 | import { Media } from '../_components/media'; 12 | import { getProjects } from '../_utils/helpers/projects.helpers'; 13 | import { ProjectInfoFragment } from '@/graphql/generated/cms.generated'; 14 | 15 | const title = 'Work'; 16 | const description = 'A curated collection of my work throughout the years.'; 17 | const url = new URL(`${PATHS.base}${PATHS.work}`); 18 | 19 | export const metadata: Metadata = { 20 | title, 21 | description, 22 | robots: 'follow, index', 23 | openGraph: { 24 | url, 25 | type: 'website', 26 | locale: 'en_US', 27 | title, 28 | description, 29 | }, 30 | }; 31 | 32 | export default async function Work() { 33 | const { projects } = await getProjects(); 34 | 35 | const featuredProject = projects.filter((project) => 36 | Boolean(project.featured), 37 | )[0]; 38 | 39 | return ( 40 |
      41 |
      42 | Back to home 43 |

      Work

      44 |
      45 | {featuredProject ? : null} 46 |
      47 |

      54 | All Work 55 |

      56 |
        62 | {projects.map((project) => { 63 | return ( 64 |
      • 65 | 69 |
      • 70 | ); 71 | })} 72 |
      73 |
      74 |
      75 | ); 76 | } 77 | 78 | interface FeaturedProjectProps { 79 | project: ProjectInfoFragment; 80 | } 81 | const FeaturedProject = ({ project }: FeaturedProjectProps) => { 82 | const { featureMediaWide, slug, name, description } = project; 83 | return ( 84 |
      85 |

      92 | Featured 93 |

      94 |
      95 |
      108 |
      109 | {featureMediaWide.mediaType != null && ( 110 |
      118 | 125 |
      126 | )} 127 |
      128 | 129 |

      136 | {name} 137 |

      138 | 139 | ( 142 |

      149 | {children} 150 |

      151 | ), 152 | }} 153 | content={description.raw as RichTextContent} 154 | /> 155 |
      156 |
      157 |
      158 |
      159 |
      160 | ); 161 | }; 162 | -------------------------------------------------------------------------------- /src/data/writings/graphql-with-api-routes.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Client-side data fetching with GraphQL and Next.js API Routes. 3 | description: 4 | Learn how to fetch data client-side in your Next.js app using a third-party 5 | GraphQL endpoint. 6 | date: 2023-02-23 7 | featured: true 8 | status: published 9 | --- 10 | 11 | In a typical Next.js app, you'll likely fetch page data server-side using either 12 | `getStaticProps` or `getServerSideProps`. However, what if you need to fetch 13 | data client-side? You might not need every piece of data to be server-side 14 | rendered or statically generated, so this is a reasonable question to ask. 15 | Moreover, if you use a GraphQL client like Apollo or Urql, data fetching hooks 16 | such as `useQuery(){:ts}` will attempt to fetch data client-side by default 17 | whenever their cache for a query is empty or stale. In this post, we'll walk 18 | through a realistic data fetching scenario using a third-party GraphQL endpoint 19 | in a Next.js app and how to fetch data client-side while keeping private API 20 | keys hidden from the client. 21 | 22 | 1. You have a Next.js app that uses a third-party GraphQL endpoint. 23 | 2. You want to fetch this data client-side. 24 | 3. You need to access this data using a private API key and don't want to expose 25 | it to the client. 26 | 27 | ## Step 1: Configure your GraphQL client 28 | 29 | For this example, we'll use GitHub's GraphQL API and graphql-request, but you 30 | could use any method you prefer. Here's how we create a GraphQL client with the 31 | necessary information to make our request to GitHub: 32 | 33 | ```ts 34 | // graphql/client.ts 35 | 36 | export const createGithubClient = () => { 37 | return new GraphQLClient('https://api.github.com/graphql', { 38 | headers: { 39 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 40 | }, 41 | }); 42 | }; 43 | ``` 44 | 45 | We pass in an Authorization header referencing our GITHUB_TOKEN environment 46 | variable, which will only be available on the server and not the client. It's 47 | best practice to keep API keys and secrets obscured from the client, and 48 | environment variables are an effective way to do so. 49 | 50 | ## Step 2: Create our API route 51 | 52 | Next, we create a Next.js API route to handle all requests to the GitHub GraphQL 53 | API from the client. Here's the code: 54 | 55 | ```ts 56 | // pages/api/github.ts 57 | 58 | import { createGithubClient } from '@/graphql/client'; 59 | import { Variables } from 'graphql-request'; 60 | import type { NextApiRequest, NextApiResponse } from 'next'; 61 | 62 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 63 | // Verify that the request method is POST 64 | if (req.method !== 'POST') { 65 | res.status(405).end(); 66 | return; 67 | } 68 | 69 | // Extract the query and variables from the request body 70 | const { query, variables } = req.body as { 71 | query: string; 72 | variables?: Variables; 73 | }; 74 | 75 | try { 76 | // Initialize our GraphQL client 77 | const client = createGithubClient(); 78 | 79 | // Make the request to GitHub's GraphQL API using our client 80 | const data = await client.request(query, variables); 81 | 82 | // Send the response data back to the client 83 | res.status(200).send(data); 84 | } catch (error) { 85 | // eslint-disable-next-line no-console 86 | console.error('error:', error); 87 | res.status(200).send('Server Error'); 88 | } 89 | }; 90 | 91 | export default handler; 92 | ``` 93 | 94 | With this code, any requests to /api/github will be handled by this API route. 95 | 96 | ## Step 3: Fetch data client-side 97 | 98 | We'll use [Urql](https://formidable.com/open-source/urql/) for this example but 99 | you could use Apollo or any other data fetching method. Here's how we set up the 100 | client in our `_app.tsx` file: 101 | 102 | ```tsx 103 | // pages/_app.tsx 104 | 105 | import { createClient, Provider } from 'urql'; 106 | 107 | const client = createClient({ 108 | // The url of our API route 109 | url: '/api/github', 110 | }); 111 | 112 | const MyApp = ({ Component, pageProps }: AppProps) => { 113 | return ( 114 | 115 | 116 | 117 | ); 118 | }; 119 | ``` 120 | 121 | Now we can use the `useQuery` hook to fetch data client-side. Here's an example 122 | of in our `index.tsx` file: 123 | 124 | ```tsx 125 | // pages/index.tsx 126 | 127 | import { useQuery } from 'urql'; 128 | 129 | const query = ` 130 | query { 131 | user(login: "h-jennings") { 132 | name 133 | bio 134 | } 135 | } 136 | `; 137 | 138 | const Home = () => { 139 | const [{ fetching, error, data }] = useQuery({ query }); 140 | 141 | if (fetching) { 142 | return
      Loading...
      ; 143 | } 144 | 145 | if (error) { 146 | return
      Oh no... {result.error.message}
      ; 147 | } 148 | 149 | const { user } = data; 150 | const { name, bio } = user; 151 | 152 | return ( 153 |
      154 |

      {name}

      155 |

      {bio}

      156 |
      157 | ); 158 | }; 159 | 160 | export default Home; 161 | ``` 162 | 163 | ## Conclusion 164 | 165 | This is a very simple example of how you can fetch data client-side with Next.js 166 | using a third-party GraphQL endpoint and an api route. I hope this helps you get 167 | started with your own projects. If you have any questions or comments, feel free 168 | to reach out to me on Twitter 169 | [@jennings_hunter](https://twitter.com/jennings_hunter). 170 | -------------------------------------------------------------------------------- /src/app/writing/page.tsx: -------------------------------------------------------------------------------- 1 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 2 | import { 3 | addYearToWritings, 4 | groupDatesByYear, 5 | parseDateToString, 6 | } from '@/app/_utils/helpers/date.helpers'; 7 | import { allWritings } from 'contentlayer/generated'; 8 | import { css, cx } from 'ds/css'; 9 | import { flex, linkBox, linkOverlay, stack } from 'ds/patterns'; 10 | import { Metadata } from 'next'; 11 | import { BackToLink } from '../_components/back-to-link'; 12 | import s from './writing.module.css'; 13 | import Link from 'next/link'; 14 | 15 | const title = 'Writing'; 16 | const description = 17 | 'Thoughts on software, books, life, and any opinions I have at a moment in time.'; 18 | const url = new URL(`${PATHS.base}${PATHS.writing}`); 19 | 20 | export const metadata: Metadata = { 21 | title, 22 | description, 23 | robots: 'follow, index', 24 | openGraph: { 25 | url, 26 | type: 'website', 27 | locale: 'en_US', 28 | title, 29 | description, 30 | }, 31 | }; 32 | 33 | export default function Writing() { 34 | const featuredWritings = allWritings.filter(({ featured }) => { 35 | return featured; 36 | }); 37 | const hasWritings = allWritings.length > 0; 38 | const hasFeaturedWritings = allWritings.some((writing) => writing.featured); 39 | const groupedWritings = groupDatesByYear(addYearToWritings(allWritings)); 40 | 41 | return ( 42 |
      43 |
      44 |
      45 | Back to home 46 |

      Writing

      47 |
      48 | {hasFeaturedWritings ? ( 49 |
      50 |

      57 | Featured 58 |

      59 |
        60 | {featuredWritings.map(({ slug, title, description }) => { 61 | return ( 62 |
      • 63 |
        70 | 75 |

        82 | {title} 83 |

        84 | 85 |

        93 | {description} 94 |

        95 |
        96 |
      • 97 | ); 98 | })} 99 |
      100 |
      101 | ) : null} 102 | {hasWritings ? ( 103 |
      104 |

      111 | All Writing 112 |

      113 |
        114 | {groupedWritings.map(({ year, writings }) => { 115 | writings.map(({}) => null); 116 | return ( 117 |
      • 118 |

        130 | {year} 131 |

        132 |
          133 | {writings.map(({ slug, title, date }) => { 134 | return ( 135 |
        • 136 |
          137 |
          145 | 149 |

          155 | {title} 156 |

          157 | 158 |

          169 | {parseDateToString(date)} 170 |

          171 |
          172 |
          173 |
        • 174 | ); 175 | })} 176 |
        177 |
      • 178 | ); 179 | })} 180 |
      181 |
      182 | ) : ( 183 |

      184 | No writings to display (yet!). 185 |

      186 | )} 187 |
      188 |
      189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /src/data/writings/fluid-typography-configuration.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configure Fluid Typography with Tailwind CSS and Vanilla Extract 3 | description: 4 | Learn how to configure fluid typography with Tailwind CSS and Vanilla Extract. 5 | date: 2023-02-25 6 | featured: true 7 | status: published 8 | --- 9 | 10 | Good typography is essential for a good web experience. If you get it right, 11 | you're in a league of your own. It almost feels like cheating at times. I don't 12 | care how good your iconography, color, or layout is, if your typography is off, 13 | it throws everything out of whack. 14 | 15 | To make matters more challenging we live in a world of near infinite screen 16 | sizes. Surely, your typography can't look good in all of those cases, right? 17 | 18 | Wrong. Meet your new best friend: `createFluidValue`. Credit to my coworker 19 | [Brett Smith](https://twitter.com/_brettsmith) for writing the original function 20 | implementation. 21 | 22 | ```ts 23 | // createFluidValue.ts 24 | 25 | /** 26 | More info: 27 | https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/ 28 | */ 29 | const DEFAULT_MIN_SCREEN = 360; 30 | const DEFAULT_MAX_SCREEN = 1040; 31 | 32 | const HTML_FONT_SIZE = 16; 33 | 34 | /** 35 | * It returns a CSS `clamp` function string that will fluidly 36 | * transition between a `minSize` and `maxSize` based on the screen size provided 37 | */ 38 | export const createFluidValue = ( 39 | minSize: number, 40 | maxSize: number, 41 | minScreenSize: number = DEFAULT_MIN_SCREEN, 42 | maxScreenSize: number = DEFAULT_MAX_SCREEN, 43 | ) => { 44 | return `clamp(${pxToRem(minSize)}, ${getPreferredValue( 45 | minSize, 46 | maxSize, 47 | minScreenSize, 48 | maxScreenSize, 49 | )}, ${pxToRem(maxSize)})`; 50 | }; 51 | 52 | /** 53 | * Determines how fluid typography scales 54 | */ 55 | const getPreferredValue = ( 56 | minSize: number, 57 | maxSize: number, 58 | minScreenSize: number, 59 | maxScreenSize: number, 60 | ) => { 61 | const vwCalc = cleanNumber( 62 | (100 * (maxSize - minSize)) / (maxScreenSize - minScreenSize), 63 | ); 64 | const remCalc = cleanNumber( 65 | (minScreenSize * maxSize - maxScreenSize * minSize) / 66 | (minScreenSize - maxScreenSize), 67 | ); 68 | 69 | return `${vwCalc}vw + ${pxToRem(remCalc)}`; 70 | }; 71 | 72 | const pxToRem = (px: number | string) => 73 | `${cleanNumber(Number(px) / HTML_FONT_SIZE)}rem`; 74 | 75 | /** 76 | * It takes a number, adds a very small number to it, multiplies it by 100, rounds 77 | * it, and then divides it by 100 78 | * @param num - The number to be rounded. 79 | */ 80 | const cleanNumber = (num: number) => 81 | Math.round((num + Number.EPSILON) * 100) / 100; 82 | ``` 83 | 84 | ## What's happening here? 85 | 86 | 1. We're defining a default min and max screen size. This defines the range of 87 | fluid typography will start and end. You can change these values to whatever 88 | you want, but I recommend choosing a range that reflects where your design 89 | starts to break down on desktop and where things start to stabilize on 90 | mobile. Think, "where does my design start to look congested on desktop?" and 91 | "where does my design "end" on mobile width-wise?" 92 | 2. We're defining the default HTML font size. This is the font size that we're 93 | going to use to calculate our fluid typography. You can change this value, 94 | however, ensure that you're using whatever's defined in your `html` element 95 | or `:root` selector. This makes sure that we're calculating the correct `rem` 96 | value. 97 | 3. We're defining a function that will calculate our fluid typography. _This is 98 | where the magic happens._ This function takes in a `minSize`, `maxSize`, 99 | `minScreenSize`, and `maxScreenSize`. The `minSize` and `maxSize` are the 100 | minimum and maximum size the font (or any property, really) will transition 101 | between. The `minScreenSize` and `maxScreenSize` upper and lower bounds of 102 | _when_ that font size will reach its minimum and maximum size. 103 | 104 | --- 105 | 106 | ## Using this function in our Tailwind config 107 | 108 | ```js 109 | // tailwind.config.js 110 | 111 | /* 112 | **NOTE**: 113 | This `createFluidValue` function needs to be a `.js` file extension 114 | and exported using `module.exports` 115 | */ 116 | const { createFluidValue } = require('./createFluidValue'); 117 | 118 | module.exports = { 119 | // ... other config options 120 | theme: { 121 | extend: { 122 | fontSize: { 123 | // NOTE: These are just example names and values 124 | 'fluid-xs': createFluidValue(12, 14), 125 | 'fluid-sm': createFluidValue(14, 16), 126 | 'fluid-base': createFluidValue(16, 18), 127 | 'fluid-lg': createFluidValue(18, 20), 128 | 'fluid-xl': createFluidValue(20, 24), 129 | 'fluid-2xl': createFluidValue(24, 28), 130 | 'fluid-3xl': createFluidValue(28, 32), 131 | }, 132 | }, 133 | }, 134 | }; 135 | ``` 136 | 137 | ### Usage in our JSX 138 | 139 | ```jsx 140 |

      Hello, world!

      141 | ``` 142 | 143 | --- 144 | 145 | ## Using this function in our Vanilla Extract [Sprinkles](https://vanilla-extract.style/documentation/packages/sprinkles/) 146 | 147 | ```ts 148 | // ds.css.ts 149 | 150 | import { createFluidValue } from './createFluidValue'; 151 | 152 | const tokenVars = createGlobalTheme(':root', { 153 | // ... other tokens 154 | fontSizes: { 155 | // NOTE: These are just example names and values 156 | 'fluid-xs': createFluidValue(12, 14), 157 | 'fluid-sm': createFluidValue(14, 16), 158 | 'fluid-base': createFluidValue(16, 18), 159 | 'fluid-lg': createFluidValue(18, 20), 160 | 'fluid-xl': createFluidValue(20, 24), 161 | 'fluid-2xl': createFluidValue(24, 28), 162 | 'fluid-3xl': createFluidValue(28, 32), 163 | }, 164 | }); 165 | 166 | export const BREAKPOINTS = { 167 | bp1: '(width >= 520px)', 168 | '= 768px)', 170 | '= 1040px)', 172 | '= 1800px)', 174 | 'Hello, world!
203 | ``` 204 | 205 | ## What's next? 206 | 207 | Remember that `createFluidValue` can be used to make _any_ property fluid, not 208 | just typography. You can use it for spacing, width, height, and more. In fact, I 209 | used it to build the typography and spacing system for this website (check it 210 | out 211 | [here](https://github.com/h-jennings/portfolio-v3/blob/4079b379815bdd06addf4233e4480bf0a9efc1d4/src/styles/create-fluid-value.ts)), 212 | as well as on several large client projects. Our team now relies on it as a core 213 | part of our responsive design approach. 214 | 215 | ✌️ Happy coding! 216 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | parseDateToLongDateString, 4 | sortArrayByDateDesc, 5 | } from '@/app/_utils/helpers/date.helpers'; 6 | import { allWritings } from 'contentlayer/generated'; 7 | import { VisuallyHiddenRoot } from './_components/visually-hidden'; 8 | import { flex, grid, hstack, stack } from 'ds/patterns'; 9 | import { css, cx } from 'ds/css'; 10 | import Link from 'next/link'; 11 | import { link } from 'ds/recipes'; 12 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 13 | import { token } from 'ds/tokens'; 14 | import ArrowRightIcon from '@/app/_components/icons/ArrowRightIcon'; 15 | import { getProjects } from './_utils/helpers/projects.helpers'; 16 | import { 17 | ScrollAreaRoot, 18 | ScrollAreaScrollbar, 19 | ScrollAreaThumb, 20 | ScrollAreaViewport, 21 | barStyles, 22 | rootStyles, 23 | thumbStyles, 24 | viewportStyles, 25 | } from './_components/scroll-area'; 26 | import { ProjectCard, ProjectCardLoadingUI } from './_components/project-card'; 27 | 28 | export default function Home() { 29 | return ( 30 |
31 | 32 |

Home

33 |
34 |
35 | 36 | 37 | }> 38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | ); 46 | } 47 | 48 | const IntroductionSection = () => { 49 | return ( 50 |
51 |
52 |

59 | Hunter Jennings 60 |

61 |

62 | User interface engineer interested in design systems, component 63 | architectures, TypeScript, and React. 64 |

65 |
66 |
67 |

71 | Now 72 |

73 |

74 | Currently working as a Senior Frontend Developer for a social venture 75 | education start-up— 76 | 80 | Breakline 81 | 82 | . 83 |

84 |
85 |
86 | ); 87 | }; 88 | 89 | const WorkSection = ({ children }: { children?: React.ReactNode }) => { 90 | return ( 91 |
92 |
93 |

94 | Selected work 95 |

96 | view all 97 |
98 | {children} 99 |
100 | ); 101 | }; 102 | 103 | const ProjectGrid = async () => { 104 | const { projects } = await getProjects(3); 105 | 106 | return ( 107 | 108 | 109 | 110 | 111 | 112 |
119 | {projects.map((project) => { 120 | return ( 121 |
122 | 126 |
127 | ); 128 | })} 129 |
130 |
131 |
132 | ); 133 | }; 134 | 135 | const ProjectGridLoadingUI = () => { 136 | return ( 137 |
145 | {Array.from({ length: 3 }, (_, i) => { 146 | return ( 147 |
148 | 149 |
150 | ); 151 | })} 152 |
153 | ); 154 | }; 155 | 156 | const cardWrapper = css({ 157 | minW: { base: '90%', bp1: '45%', bp2: '220px' }, 158 | ml: 's', 159 | _firstOfType: { ml: 'none' }, 160 | }); 161 | 162 | const WritingsSection = () => { 163 | const writings = sortArrayByDateDesc(allWritings); 164 | const hasWritings = writings.length > 0; 165 | 166 | if (!hasWritings) return null; 167 | 168 | return ( 169 |
170 |
171 |

172 | Writing 173 |

174 | view all 175 |
176 |
    177 | {writings.map(({ _id, slug, title, date }) => { 178 | return ( 179 |
  • 183 |
    184 | 188 | {title} 189 | 190 |
    191 | 199 |
  • 200 | ); 201 | })} 202 |
203 |
204 | ); 205 | }; 206 | 207 | const writingsListItem = css({ 208 | pos: 'relative', 209 | _after: { 210 | content: '" "', 211 | w: 'full', 212 | h: 0, 213 | borderTop: '1px dashed', 214 | borderColor: 'slate8', 215 | pos: 'absolute', 216 | bottom: 'calc((var(--spacing-m) / 2) * -1)', 217 | left: 0, 218 | }, 219 | }); 220 | 221 | const ArrowLink = ({ 222 | href, 223 | children, 224 | }: React.PropsWithChildren<{ href: string }>) => { 225 | return ( 226 |
227 | 237 | {children} 238 | 239 | 240 |
241 | ); 242 | }; 243 | 244 | const ConnectSection = () => { 245 | return ( 246 |
247 |

248 | Connect 249 |

250 |
251 |

252 | I'm not currently looking for new opportunities, but feel free to 253 | reach out if you'd like. I'm always happy to hear from folks 254 | and talk shop. 255 |

256 |
    257 | 258 | 259 | @jennings_hunter 260 | 261 | 262 | 263 | 264 | jenningsdhunter@gmail.com 265 | 266 | 267 | 268 | h-jennings 269 | 270 | 271 | 272 | read.cv/hunterjennings 273 | 274 | 275 |
276 |
277 |
278 | ); 279 | }; 280 | 281 | interface ConnectLinkListItemProps { 282 | label: string; 283 | } 284 | 285 | const ConnectLinkListItem = ({ 286 | label, 287 | children, 288 | }: React.PropsWithChildren) => { 289 | return ( 290 |
  • 291 |

    298 | {label} 299 |

    300 |
    301 |
    {children}
    302 |
    303 |
  • 304 | ); 305 | }; 306 | 307 | const ConnectListLink = ({ 308 | children, 309 | href, 310 | }: React.PropsWithChildren<{ href: string }>) => { 311 | return ( 312 | 321 | {children} 322 | 323 | ); 324 | }; 325 | -------------------------------------------------------------------------------- /src/app/work/[project]/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { PATHS } from '@/app/_utils/constants/paths.constants'; 3 | import { flex, grid, stack } from 'ds/patterns'; 4 | import { Metadata } from 'next'; 5 | import { BackToLink } from '../../_components/back-to-link'; 6 | import { css, cva } from 'ds/css'; 7 | import { getYear } from 'date-fns'; 8 | import Link from 'next/link'; 9 | import { ArrowTopRightIcon } from '@/app/_components/icons/ArrowTopRightIcon'; 10 | import { RichText } from '@graphcms/rich-text-react-renderer'; 11 | import { RichTextContent } from '@graphcms/rich-text-types'; 12 | import { Media } from '@/app/_components/media'; 13 | import { 14 | ScrollAreaRoot, 15 | ScrollAreaScrollbar, 16 | ScrollAreaThumb, 17 | ScrollAreaViewport, 18 | barStyles, 19 | rootStyles, 20 | thumbStyles, 21 | viewportStyles, 22 | } from '@/app/_components/scroll-area'; 23 | import { MoreProjects } from './_components/more-projects'; 24 | import { getProject } from '../../_utils/helpers/projects.helpers'; 25 | import { ProjectInfoFragment } from '@/graphql/generated/cms.generated'; 26 | import notFound from './not-found'; 27 | 28 | export const generateMetadata = async ({ 29 | params, 30 | }: { 31 | params: { project: string }; 32 | }): Promise => { 33 | const data = await getProject(params.project); 34 | 35 | const { project } = data; 36 | 37 | if (!project) { 38 | return {}; 39 | } 40 | 41 | const { seo } = project; 42 | 43 | const url = new URL(`${PATHS.base}${PATHS.work}/${params.project}`); 44 | const title = seo.title; 45 | const description = seo.description ?? undefined; 46 | 47 | return { 48 | title, 49 | description, 50 | openGraph: { 51 | url, 52 | type: 'article', 53 | authors: ['https://twitter.com/jennings_hunter'], 54 | locale: 'en_US', 55 | images: 56 | seo.image?.url != null 57 | ? [ 58 | { 59 | url: seo.image.url, 60 | }, 61 | ] 62 | : undefined, 63 | title, 64 | description, 65 | }, 66 | }; 67 | }; 68 | 69 | export default async function Project({ 70 | params, 71 | }: { 72 | params: { project: string }; 73 | }) { 74 | const data = await getProject(params.project); 75 | 76 | if (!data.project) { 77 | return notFound(); 78 | } 79 | 80 | const { project } = data; 81 | 82 | const { name, client, contribution, date, link, media, descriptionLong } = 83 | project; 84 | 85 | return ( 86 |
    87 |
    88 | 89 | 90 | 91 |
    92 | 93 |
    100 | 101 | {link != null && } 102 |
    103 |
    104 |
    105 |
    106 |

    113 | Other Projects 114 |

    115 | 116 | {/* TODO: add fallback */} 117 | 118 | 119 |
    120 |
    121 | ); 122 | } 123 | 124 | interface ProjectHeaderProps { 125 | name?: string; 126 | client?: string; 127 | } 128 | const ProjectHeader = ({ name, client }: ProjectHeaderProps) => { 129 | return ( 130 |
    131 | Back to work 132 |
    141 |

    {name}

    142 | {client != null && ( 143 |

    150 | {client} 151 |

    152 | )} 153 |
    154 |
    155 | ); 156 | }; 157 | 158 | interface ProjectMediaProps { 159 | media: ProjectInfoFragment['media']; 160 | } 161 | const ProjectMedia = ({ media }: ProjectMediaProps) => { 162 | return ( 163 | 164 | 165 | 166 | 167 | 168 |
    179 | {media.map(({ mediaType, url }, idx) => { 180 | if (mediaType == null) return null; 181 | const isEven = (idx + 1) % 2 === 0; 182 | const width = isEven ? 220 : 460; 183 | const height = 275; 184 | 185 | const item = (idx % 3) as 0 | 1 | 2; 186 | const sizes = [ 187 | '(max-width: 519px) 78vw, (max-width: 740px) 60vw, 460px', 188 | '(max-width: 519px) 38vw, (max-width: 740px) 30vw, 220px', 189 | '(max-width: 519px) 120vw, (max-width: 740px) 100vw, 418px', 190 | ] as const; 191 | 192 | return ( 193 |
    194 | 201 |
    202 | ); 203 | })} 204 |
    205 |
    206 |
    207 | ); 208 | }; 209 | 210 | interface ProjectDescriptionProps { 211 | descriptionLong: ProjectInfoFragment['descriptionLong']; 212 | } 213 | const ProjectDescription = ({ descriptionLong }: ProjectDescriptionProps) => { 214 | return ( 215 |
    216 |

    224 | Description 225 |

    226 | ( 229 |

    235 | {children} 236 |

    237 | ), 238 | }} 239 | content={descriptionLong.raw as RichTextContent} 240 | /> 241 |
    242 | ); 243 | }; 244 | 245 | interface ProjectContributionsProps { 246 | contribution: string[]; 247 | } 248 | const ProjectContributions = ({ contribution }: ProjectContributionsProps) => { 249 | return ( 250 |
    251 |

    259 | Contributions 260 |

    261 |
      262 | {contribution.map((c, i) => ( 263 |
    • 269 | {c} 270 |
    • 271 | ))} 272 |
    273 |
    274 | ); 275 | }; 276 | 277 | interface ProjectDatesProps { 278 | date: string[]; 279 | } 280 | const ProjectDates = ({ date }: ProjectDatesProps) => { 281 | return ( 282 |
    283 |

    291 | Dates 292 |

    293 |

    300 | {date.map((d, i) => { 301 | return ( 302 | 310 | {i > 0 ? ' - ' : ''} 311 | {getYear(new Date(d))} 312 | 313 | ); 314 | })} 315 |

    316 |
    317 | ); 318 | }; 319 | 320 | interface ProjectLinkProps { 321 | link: string; 322 | } 323 | const ProjectLink = ({ link }: ProjectLinkProps) => { 324 | return ( 325 |
    326 | 327 | Visit Site 328 | 329 | 330 |
    331 | ); 332 | }; 333 | 334 | const chip = cva({ 335 | base: { 336 | whiteSpace: 'nowrap', 337 | fontSize: 12, 338 | px: '2xs', 339 | py: '3xs', 340 | rounded: 'pill', 341 | lineHeight: 'tight', 342 | }, 343 | variants: { 344 | variant: { 345 | darker: { 346 | backgroundColor: 'gold5', 347 | color: 'gold10', 348 | }, 349 | default: { 350 | backgroundColor: 'gold7', 351 | color: 'gold10', 352 | }, 353 | }, 354 | }, 355 | defaultVariants: { 356 | variant: 'default', 357 | }, 358 | }); 359 | 360 | const buttonLink = css({ 361 | display: 'inline-flex', 362 | gap: '2xs', 363 | lineHeight: 'tight', 364 | justifyContent: 'center', 365 | alignItems: 'center', 366 | fontSize: '1', 367 | px: 's', 368 | transition: 'default', 369 | bgColor: 'surface1', 370 | rounded: 5, 371 | border: '1px solid', 372 | borderColor: 'surface2', 373 | minW: 90, 374 | minH: 40, 375 | transitionProperty: 'backgroundColor, opacity', 376 | _hover: { 377 | bgColor: 'slate4', 378 | }, 379 | }); 380 | 381 | const mediaContainer = cva({ 382 | base: { 383 | isolation: 'isolate', 384 | overflow: 'hidden', 385 | rounded: 'card', 386 | h: 'full', 387 | bgColor: 'slate8', 388 | }, 389 | variants: { 390 | item: { 391 | 0: { 392 | gridColumn: '1 / span 2', 393 | }, 394 | 1: { 395 | gridColumn: '3 / -1', 396 | }, 397 | 2: { 398 | gridColumn: '1 / -1', 399 | }, 400 | }, 401 | }, 402 | }); 403 | --------------------------------------------------------------------------------