├── .env.local.example
├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── .vscode
└── settings.json
├── README.md
├── app
├── about
│ └── page.tsx
├── api
│ ├── disable-draft
│ │ └── route.ts
│ ├── draft
│ │ └── route.ts
│ └── revalidate
│ │ └── route.ts
├── contact
│ └── page.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── not-found.tsx
├── page.tsx
└── work
│ ├── [slug]
│ └── page.tsx
│ └── page.tsx
├── components.json
├── components
├── about-promo.tsx
├── animated-text.tsx
├── contact-details.tsx
├── footer.tsx
├── hero-alt.tsx
├── hero.tsx
├── image-list.tsx
├── intro-promo.tsx
├── menu.tsx
├── mode-toggle.tsx
├── preview.tsx
├── project-card.tsx
├── project-list.tsx
├── scroll-behavior.tsx
├── theme-provider.tsx
├── ui
│ └── aspect-ratio.tsx
└── work-promo.tsx
├── lib
├── api.ts
├── contentful-image.tsx
├── markdown.tsx
└── utils.ts
├── next-sitemap.config.js
├── next.config.mjs
├── package.json
├── postcss.config.js
├── tailwind.config.ts
├── tsconfig.json
└── yarn.lock
/.env.local.example:
--------------------------------------------------------------------------------
1 | SITE_URL=
2 | CONTENTFUL_SPACE_ID=
3 | CONTENTFUL_PREVIEW_SECRET=
4 | CONTENTFUL_REVALIDATE_SECRET=
5 | CONTENTFUL_ACCESS_TOKEN=
6 | CONTENTFUL_PREVIEW_ACCESS_TOKEN=
7 | CONTENTFUL_ENVIRONMENT=
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["@typescript-eslint"],
3 | "extends": [
4 | "next/core-web-vitals",
5 | "plugin:@typescript-eslint/recommended",
6 | "prettier"
7 | ],
8 | "rules": {
9 | "@typescript-eslint/no-unused-vars": "off",
10 | "@typescript-eslint/no-explicit-any": "off"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.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 | /public
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "trailingComma": "es5",
4 | "singleQuote": true,
5 | "tabWidth": 2,
6 | "useTabs": false
7 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": "explicit"
4 | },
5 | "editor.formatOnSave": true,
6 | "editor.defaultFormatter": "esbenp.prettier-vscode"
7 | }
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # README
2 |
3 | Ryan Wiemer's personal website. This repo is really only intended for me but feel free to look around.
4 |
5 | Technology used:
6 |
7 | - Next.js
8 | - Contentful
9 | - Tailwind CSS
10 | - Framer Motion
11 |
--------------------------------------------------------------------------------
/app/about/page.tsx:
--------------------------------------------------------------------------------
1 | import { draftMode } from 'next/headers'
2 | import { getPageBySlug } from '@/lib/api'
3 | import HeroAlt from '@/components/hero-alt'
4 | import { Markdown } from '@/lib/markdown'
5 | import { getPlaiceholder } from 'plaiceholder'
6 |
7 | export async function generateMetadata() {
8 | const { isEnabled } = draftMode()
9 | const page = await getPageBySlug('about', isEnabled)
10 | return {
11 | title: page.title,
12 | description: page.description,
13 | openGraph: {
14 | images: [page.cover.url],
15 | },
16 | }
17 | }
18 |
19 | export default async function Page() {
20 | const { isEnabled } = draftMode()
21 | const page = await getPageBySlug('about', isEnabled)
22 |
23 | // Generate the base64 for the blur-up effect for each image
24 | await Promise.all(
25 | page.imagesCollection.items.map(async (image: any) => {
26 | if (!image.url.endsWith('.mp4')) {
27 | const buffer = await fetch(image.url).then(async (res) => {
28 | return Buffer.from(await res.arrayBuffer())
29 | })
30 | const { base64 } = await getPlaiceholder(buffer, { size: 10 })
31 | image.base64 = base64
32 | }
33 | })
34 | )
35 |
36 | return (
37 | <>
38 |
43 |
48 | >
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/app/api/disable-draft/route.ts:
--------------------------------------------------------------------------------
1 | import { draftMode } from 'next/headers'
2 | import { redirect } from 'next/navigation'
3 |
4 | export async function GET(request: Request) {
5 | const { searchParams } = new URL(request.url)
6 |
7 | draftMode().disable()
8 |
9 | redirect(searchParams.get('redirect') || '/')
10 | }
11 |
--------------------------------------------------------------------------------
/app/api/draft/route.ts:
--------------------------------------------------------------------------------
1 | import { draftMode } from 'next/headers'
2 | import { redirect } from 'next/navigation'
3 | import { getPreviewProjectBySlug } from '../../../lib/api'
4 | import { getPreviewPageBySlug } from '../../../lib/api'
5 |
6 | export async function GET(request: Request) {
7 | const { searchParams } = new URL(request.url)
8 | const secret = searchParams.get('secret')
9 | const slug = searchParams.get('slug')
10 |
11 | if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
12 | return new Response('Invalid token', { status: 401 })
13 | }
14 |
15 | const project = await getPreviewProjectBySlug(slug)
16 | const page = await getPreviewPageBySlug(slug)
17 |
18 | draftMode().enable()
19 |
20 | if (project) {
21 | redirect(`/work/${project.slug}`)
22 | }
23 | if (page) {
24 | redirect(`/${page.slug}`)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/api/revalidate/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server'
2 | import { revalidateTag } from 'next/cache'
3 |
4 | export async function POST(request: NextRequest) {
5 | const requestHeaders = new Headers(request.headers)
6 | const secret = requestHeaders.get('x-vercel-reval-key')
7 |
8 | if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
9 | return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
10 | }
11 |
12 | // Revalidate the cache for all of the data pulled from Contentful in lib/api.ts
13 | revalidateTag('contentfulData')
14 | return NextResponse.json({ revalidated: true, now: Date.now() })
15 | }
16 |
--------------------------------------------------------------------------------
/app/contact/page.tsx:
--------------------------------------------------------------------------------
1 | import { draftMode } from 'next/headers'
2 | import { getPageBySlug } from '@/lib/api'
3 | import AnimatedText from '@/components/animated-text'
4 | import ContactDetails from '@/components/contact-details'
5 |
6 | export async function generateMetadata() {
7 | const { isEnabled } = draftMode()
8 | const page = await getPageBySlug('contact', isEnabled)
9 | return {
10 | title: page.title,
11 | description: page.description,
12 | openGraph: {
13 | images: [page.cover.url],
14 | },
15 | }
16 | }
17 |
18 | export default async function Page() {
19 | const { isEnabled } = draftMode()
20 | const page = await getPageBySlug('contact', isEnabled)
21 |
22 | return (
23 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ryanwiemer/rw/d1997ddb25494bdebd1823b387d5404c09a53a5a/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer utilities {
6 | .writing-vertical-rl {
7 | writing-mode: vertical-rl;
8 | }
9 | }
10 |
11 | @media (max-width: 767px) {
12 | .contain {
13 | overflow: hidden;
14 | }
15 | }
16 |
17 | .no-scroll {
18 | overflow: hidden;
19 | }
20 |
21 | .no-mix-blend header.closed a,
22 | .no-mix-blend header.closed button,
23 | .no-mix-blend header {
24 | mix-blend-mode: normal !important;
25 | }
26 |
27 | .gradient-mask-custom {
28 | mask-image: linear-gradient(
29 | to left,
30 | rgba(0, 0, 0, 0) 0%,
31 | rgba(0, 0, 0, 1) 10%,
32 | rgba(0, 0, 0, 1) 90%,
33 | rgba(0, 0, 0, 0) 100%
34 | );
35 | }
36 |
37 | @layer base {
38 | :root {
39 | --background: 0 0% 100%;
40 | --foreground: 240 10% 3.9%;
41 |
42 | --card: 0 0% 100%;
43 | --card-foreground: 240 10% 3.9%;
44 |
45 | --popover: 0 0% 100%;
46 | --popover-foreground: 240 10% 3.9%;
47 |
48 | --primary: 240 5.9% 10%;
49 | --primary-foreground: 0 0% 98%;
50 |
51 | --secondary: 240 4.8% 95.9%;
52 | --secondary-foreground: 240 5.9% 10%;
53 |
54 | --muted: 240 4.8% 95.9%;
55 | --muted-foreground: 240 3.8% 46.1%;
56 |
57 | --accent: 240 4.8% 95.9%;
58 | --accent-foreground: 240 5.9% 10%;
59 |
60 | --destructive: 0 84.2% 60.2%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 5.9% 90%;
64 | --input: 240 5.9% 90%;
65 | --ring: 240 10% 3.9%;
66 |
67 | --radius: 0.5rem;
68 | }
69 |
70 | .dark {
71 | --background: 240 10% 3.9%;
72 | --foreground: 0 0% 98%;
73 |
74 | --card: 240 10% 3.9%;
75 | --card-foreground: 0 0% 98%;
76 |
77 | --popover: 240 10% 3.9%;
78 | --popover-foreground: 0 0% 98%;
79 |
80 | --primary: 0 0% 98%;
81 | --primary-foreground: 240 5.9% 10%;
82 |
83 | --secondary: 240 3.7% 15.9%;
84 | --secondary-foreground: 0 0% 98%;
85 |
86 | --muted: 240 3.7% 15.9%;
87 | --muted-foreground: 240 5% 64.9%;
88 |
89 | --accent: 240 3.7% 15.9%;
90 | --accent-foreground: 0 0% 98%;
91 |
92 | --destructive: 0 62.8% 30.6%;
93 | --destructive-foreground: 0 0% 98%;
94 |
95 | --border: 240 3.7% 15.9%;
96 | --input: 240 3.7% 15.9%;
97 | --ring: 240 4.9% 83.9%;
98 | }
99 | }
100 |
101 | @layer base {
102 | * {
103 | @apply border-border;
104 | }
105 | body {
106 | @apply bg-background text-foreground;
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 | import { Inter } from 'next/font/google'
3 | import { Analytics } from '@vercel/analytics/react'
4 | import { SpeedInsights } from '@vercel/speed-insights/next'
5 | import Footer from '../components/footer'
6 | import Menu from '../components/menu'
7 | import { ThemeProvider } from '@/components/theme-provider'
8 | import ScrollBehavior from '@/components/scroll-behavior'
9 |
10 | export const metadata = {
11 | metadataBase: new URL('https://rw-poc.vercel.app/'),
12 | title: {
13 | template: '%s | Ryan Wiemer',
14 | default: 'Ryan Wiemer',
15 | },
16 | description: `Client engagement director, project manager, and occasional open source developer. I help companies build and launch digital products.`,
17 | }
18 |
19 | const inter = Inter({
20 | variable: '--font-inter',
21 | subsets: ['latin'],
22 | display: 'swap',
23 | })
24 |
25 | export default async function RootLayout({
26 | children,
27 | }: {
28 | children: React.ReactNode
29 | }) {
30 | return (
31 |
32 |
33 |
34 |
40 |
41 |
42 | {children}
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | export default function NotFound() {
2 | return (
3 |
4 |
5 |
6 |
7 | 404
8 |
9 |
Page not found.
10 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { draftMode } from 'next/headers'
2 | import { getAllProjects, getPageBySlug } from '@/lib/api'
3 | import IntroPromo from '@/components/intro-promo'
4 | import AboutPromo from '@/components/about-promo'
5 | import WorkPromo from '@/components/work-promo'
6 | import { getPlaiceholder } from 'plaiceholder'
7 |
8 | export async function generateMetadata() {
9 | const { isEnabled } = draftMode()
10 | const page = await getPageBySlug('home', isEnabled)
11 | return {
12 | description: page.description,
13 | openGraph: {
14 | images: [page.cover.url],
15 | videos: [page.videosCollection.items[0].url],
16 | },
17 | }
18 | }
19 |
20 | export default async function Page() {
21 | const { isEnabled } = draftMode()
22 | const page = await getPageBySlug('home', isEnabled)
23 | const allProjects = await getAllProjects(isEnabled)
24 |
25 | // Generate the base64 for the blur-up effect for each project
26 | await Promise.all(
27 | allProjects.map(async (project) => {
28 | const buffer = await fetch(project.screenshot.url).then(async (res) => {
29 | return Buffer.from(await res.arrayBuffer())
30 | })
31 | const { base64 } = await getPlaiceholder(buffer, { size: 10 })
32 | project.screenshot.base64 = base64
33 | })
34 | )
35 |
36 | return (
37 | <>
38 |
43 |
44 |
45 | >
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/app/work/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Markdown } from '@/lib/markdown'
2 | import { draftMode } from 'next/headers'
3 | import { getPlaiceholder } from 'plaiceholder'
4 | import Hero from '../../../components/hero'
5 | import { getAllProjects, getProjectAndNextProject } from '@/lib/api'
6 | import ImageList from '@/components/image-list'
7 | import Preview from '@/components/preview'
8 | import { MousePointerClick } from 'lucide-react'
9 |
10 | export async function generateStaticParams() {
11 | const allProjects = await getAllProjects(false)
12 |
13 | return allProjects.map((project) => ({
14 | slug: project.slug,
15 | }))
16 | }
17 |
18 | export async function generateMetadata({
19 | params,
20 | }: {
21 | params: { slug: string }
22 | }) {
23 | const { isEnabled } = draftMode()
24 | const { project } = await getProjectAndNextProject(params.slug, isEnabled)
25 |
26 | return {
27 | title: project.title,
28 | description: project.description,
29 | openGraph: {
30 | images: [project.cover.url],
31 | },
32 | }
33 | }
34 |
35 | export default async function PostPage({
36 | params,
37 | }: {
38 | params: { slug: string }
39 | }) {
40 | const { isEnabled } = draftMode()
41 | const { project, nextProject } = await getProjectAndNextProject(
42 | params.slug,
43 | isEnabled
44 | )
45 |
46 | // Generate the base64 for the blur-up effect for the cover image
47 | const coverBuffer = await fetch(project.cover.url).then(async (res) => {
48 | return Buffer.from(await res.arrayBuffer())
49 | })
50 | const { base64: coverBase64 } = await getPlaiceholder(coverBuffer, {
51 | size: 10,
52 | })
53 | project.cover.base64 = coverBase64
54 |
55 | // Generate the base64 for the blur-up effect for each image
56 | await Promise.all(
57 | project.imagesCollection.items.map(async (image: any) => {
58 | if (!image.url.endsWith('.mp4')) {
59 | const buffer = await fetch(image.url).then(async (res) => {
60 | return Buffer.from(await res.arrayBuffer())
61 | })
62 | const { base64 } = await getPlaiceholder(buffer, { size: 10 })
63 | image.base64 = base64
64 | }
65 | })
66 | )
67 | // Generate the base64 for the blur-up effect for the next project's cover image
68 | if (nextProject) {
69 | const nextCoverBuffer = await fetch(nextProject.cover.url).then(
70 | async (res) => {
71 | return Buffer.from(await res.arrayBuffer())
72 | }
73 | )
74 | const { base64: nextCoverBase64 } = await getPlaiceholder(nextCoverBuffer, {
75 | size: 10,
76 | })
77 | nextProject.cover.base64 = nextCoverBase64
78 | }
79 |
80 | return (
81 | <>
82 |
83 |
84 |
85 |
86 |
87 |
88 | Overview
89 |
90 |
91 | {project.content &&
}
92 |
93 | {project.role && (
94 | -
95 | My Role
96 |
97 | {project.role}
98 |
99 |
100 | )}
101 | {project.client && (
102 | -
103 | Client
104 |
105 | {project.client}
106 |
107 |
108 | )}
109 | {project.category && (
110 | -
111 | Category
112 |
113 | {project.category} Project
114 |
115 |
116 | )}
117 | {project.year && (
118 | -
119 | Year
120 |
121 | {project.year}
122 |
123 |
124 | )}
125 | {project.technology && (
126 | -
127 | Technology
128 |
129 | {project.technology}
130 |
131 |
132 | )}
133 | {project.url && (
134 | -
135 | Website
136 |
142 | View Live{' '}
143 |
147 |
148 |
149 | )}
150 |
151 |
152 |
153 |
154 |
155 |
160 |
161 | {nextProject && (
162 |
167 | )}
168 | >
169 | )
170 | }
171 |
--------------------------------------------------------------------------------
/app/work/page.tsx:
--------------------------------------------------------------------------------
1 | import { draftMode } from 'next/headers'
2 | import { getAllProjects, getPageBySlug } from '@/lib/api'
3 | import ProjectList from '@/components/project-list'
4 | import { getPlaiceholder } from 'plaiceholder'
5 |
6 | export async function generateMetadata() {
7 | const { isEnabled } = draftMode()
8 | const page = await getPageBySlug('work', isEnabled)
9 | return {
10 | title: page.title,
11 | description: page.description,
12 | openGraph: {
13 | images: [page.cover.url],
14 | },
15 | }
16 | }
17 |
18 | export default async function ProjectPage() {
19 | const { isEnabled } = draftMode()
20 | const allProjects = await getAllProjects(isEnabled)
21 | const page = await getPageBySlug('work', isEnabled)
22 |
23 | // Generate the base64 for the blur-up effect for each project
24 | await Promise.all(
25 | allProjects.map(async (project) => {
26 | const buffer = await fetch(project.screenshot.url).then(async (res) => {
27 | return Buffer.from(await res.arrayBuffer())
28 | })
29 | const { base64 } = await getPlaiceholder(buffer, { size: 10 })
30 | project.screenshot.base64 = base64
31 | })
32 | )
33 |
34 | return (
35 | <>
36 |
43 | >
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/components/about-promo.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { motion } from 'framer-motion'
3 | import Link from 'next/link'
4 | import { Markdown } from '@/lib/markdown'
5 |
6 | export default function AboutPromo({ content }: { content?: any }) {
7 | return (
8 |
9 |
10 |
11 | About
12 |
13 |
14 |
15 | {content && }
16 |
17 |
21 | Get to know me
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/components/animated-text.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { motion, useInView } from 'framer-motion'
5 | import { useRef } from 'react'
6 |
7 | const defaultVariants = {
8 | hidden: { opacity: 1, y: '200%' },
9 | visible: {
10 | opacity: 1,
11 | y: 0,
12 | transition: {
13 | duration: 0.75,
14 | ease: [0.2, 0.65, 0.3, 0.9],
15 | },
16 | },
17 | }
18 |
19 | export default function AnimatedText({
20 | text,
21 | el: Wrapper = 'span',
22 | className,
23 | word,
24 | }: {
25 | text: string | string[]
26 | el?: keyof JSX.IntrinsicElements
27 | className?: string
28 | word?: boolean
29 | }) {
30 | const textArray = Array.isArray(text) ? text : [text]
31 | const ref = useRef(null)
32 | const isInView = useInView(ref, { amount: 0.5, once: true })
33 |
34 | return (
35 |
36 | {text}
37 |
44 | {word ? (
45 | <>
46 | {textArray.map((line, index) => (
47 |
48 | {line.split(' ').map((word, index) => (
49 |
50 |
55 | {word}
56 |
57 |
58 |
59 | ))}
60 |
61 | ))}
62 | >
63 | ) : (
64 | <>
65 | {textArray.map((line, index) => (
66 |
67 | {line.split(' ').map((word, index) => (
68 |
69 | {word.split('').map((char, index) => (
70 |
75 | {char}
76 |
77 | ))}
78 |
79 |
80 | ))}
81 |
82 | ))}
83 | >
84 | )}
85 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/components/contact-details.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { motion, useInView } from 'framer-motion'
3 | import { useRef } from 'react'
4 | import { ArrowUpRight } from 'lucide-react'
5 |
6 | export default function ContactDetails() {
7 | const defaultVariants = {
8 | hidden: { y: '100%', opacity: 0 },
9 | visible: {
10 | opacity: 1,
11 | y: 0,
12 | transition: {
13 | duration: 0.5,
14 | ease: [0.2, 0.65, 0.3, 0.9],
15 | },
16 | },
17 | }
18 | const ref = useRef(null)
19 | const isInView = useInView(ref, { amount: 0.5, once: true })
20 | return (
21 |
27 |
28 |
32 | Location
33 |
34 |
35 | Oakland, CA
36 |
37 |
38 |
39 |
43 | Email
44 |
45 |
46 |
50 | ryan@ryanwiemer.com
51 |
55 |
56 |
57 |
58 |
59 |
63 | LinkedIn
64 |
65 |
66 |
72 | ryanwiemer
73 |
77 |
78 |
79 |
80 |
81 |
85 | GitHub
86 |
87 |
88 |
94 | @ryanwiemer
95 |
99 |
100 |
101 |
102 |
103 | )
104 | }
105 |
--------------------------------------------------------------------------------
/components/footer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Link from 'next/link'
4 | import { usePathname } from 'next/navigation'
5 |
6 | export default function Footer() {
7 | const pathname = usePathname()
8 | if (pathname.startsWith('/work/')) {
9 | return null
10 | }
11 | return (
12 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/components/hero-alt.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { motion } from 'framer-motion'
4 | import ContentfulImage from '../lib/contentful-image'
5 | import AnimatedText from './animated-text'
6 | import { AspectRatio } from './ui/aspect-ratio'
7 |
8 | export default function HeroAlt({
9 | heading,
10 | subheading,
11 | images,
12 | }: {
13 | heading: string
14 | subheading?: string
15 | images?: any
16 | }) {
17 | //Marquee effect on the images
18 | const marqueeVariants = {
19 | animate: {
20 | x: ['0%', '-100%'],
21 | transition: {
22 | x: {
23 | repeat: Infinity,
24 | repeatType: 'loop',
25 | duration: 100,
26 | ease: 'linear',
27 | },
28 | },
29 | },
30 | }
31 |
32 | return (
33 | <>
34 |
35 |
36 |
37 | {heading && (
38 |
39 |
40 |
41 | )}
42 | {subheading && (
43 |
44 |
45 |
46 | )}
47 |
48 |
49 |
50 |
51 |
62 | {images?.map((image: any, index: number) => (
63 |
64 |
65 | {image.url && image.url.endsWith('.mp4') ? (
66 |
76 | ) : (
77 |
87 | )}
88 |
89 |
90 | ))}
91 |
92 |
103 | {images?.map((image: any, index: number) => (
104 |
105 |
106 |
114 |
115 |
116 | ))}
117 |
118 |
119 |
120 | >
121 | )
122 | }
123 |
--------------------------------------------------------------------------------
/components/hero.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRef, useEffect } from 'react'
4 | import { motion, useScroll, useTransform, useInView } from 'framer-motion'
5 | import ContentfulImage from '../lib/contentful-image'
6 | import AnimatedText from './animated-text'
7 |
8 | export default function Hero({
9 | heading,
10 | image,
11 | }: {
12 | heading: string
13 | image?: any
14 | }) {
15 | // Subtle effect on scroll
16 | const ref = useRef(null)
17 | const { scrollYProgress } = useScroll({
18 | target: ref,
19 | offset: ['start start', 'end start'],
20 | })
21 | const slow = useTransform(scrollYProgress, [0, 1], ['0%', '20%'])
22 |
23 | // Add or remove class on body element to disable mix-blend-mode when the Hero is visible
24 | const isInView = useInView(ref, {
25 | once: false,
26 | })
27 |
28 | useEffect(() => {
29 | const body = document.querySelector('body')
30 | if (isInView && body) {
31 | body.classList.add('no-mix-blend')
32 | } else if (body) {
33 | body.classList.remove('no-mix-blend')
34 | }
35 | }, [isInView])
36 |
37 | return (
38 | <>
39 |
43 |
49 |
58 |
59 |
60 |
61 |
62 | {heading && (
63 |
64 |
65 |
66 | )}
67 |
68 |
69 |
70 | >
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/components/image-list.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import ContentfulImage from '@/lib/contentful-image'
4 | import { motion } from 'framer-motion'
5 |
6 | export default function ImageList({ images }: { images: any }) {
7 | return (
8 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/components/intro-promo.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRef, useState } from 'react'
4 | import { motion, useScroll, useTransform } from 'framer-motion'
5 | import AnimatedText from './animated-text'
6 | import { AspectRatio } from './ui/aspect-ratio'
7 | import { MicOffIcon, XIcon, MonitorOffIcon } from 'lucide-react'
8 | import { useWindowSize } from '@uidotdev/usehooks'
9 |
10 | export default function IntroPromo({
11 | heading,
12 | subheading,
13 | videos,
14 | }: {
15 | heading: string
16 | subheading: string
17 | videos?: any
18 | }) {
19 | // Subtle effect on scroll
20 | const ref = useRef(null)
21 | const { scrollYProgress } = useScroll({
22 | offset: [0, '100vh'],
23 | })
24 |
25 | const screen = useWindowSize()
26 | const isMobile = screen?.width && screen.width < 768
27 | const fade = useTransform(scrollYProgress, [0, 0.5], [1, isMobile ? 1 : 0])
28 |
29 | // Tile interactions
30 | const [isClosed, setIsClosed] = useState(false)
31 | const [currentVideo, setCurrentVideo] = useState(videos[0].url)
32 |
33 | const constraintsRef = useRef(null)
34 |
35 | function close() {
36 | setIsClosed(true)
37 | setTimeout(() => {
38 | setCurrentVideo(
39 | currentVideo === videos[0].url ? videos[1].url : videos[0].url
40 | )
41 | }, 300)
42 | }
43 |
44 | function open() {
45 | setIsClosed(false)
46 | }
47 |
48 | const tileVariants = {
49 | hidden: {
50 | opacity: 0,
51 | },
52 | visible: {
53 | opacity: 1,
54 | transition: {
55 | duration: 1,
56 | ease: 'linear',
57 | },
58 | },
59 | }
60 |
61 | return (
62 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | {isClosed && (
76 |
86 | )}
87 |
101 |
105 |
106 |
117 |
124 |
125 |
129 | Ryan Wiemer
130 |
131 |
132 |
133 |
134 |
135 | )
136 | }
137 |
--------------------------------------------------------------------------------
/components/menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState, useEffect } from 'react'
3 | import Link from 'next/link'
4 | import { stagger, useAnimate, motion } from 'framer-motion'
5 | import { ModeToggle } from './mode-toggle'
6 | import { usePathname } from 'next/navigation'
7 | import { ArrowUpRight } from 'lucide-react'
8 |
9 | function useMenuAnimation(isOpen: boolean) {
10 | const [scope, animate] = useAnimate()
11 |
12 | useEffect(() => {
13 | const menuAnimations = isOpen
14 | ? [
15 | [
16 | 'nav',
17 | { opacity: 1, x: 0 },
18 | {
19 | ease: [0.08, 0.65, 0.53, 0.96],
20 | },
21 | ],
22 | [
23 | 'li',
24 | { transform: 'scale(1)', opacity: 1, filter: 'blur(0px)' },
25 | { delay: stagger(0.05), at: '-0.1' },
26 | ],
27 | ]
28 | : [
29 | [
30 | 'li',
31 | { transform: 'scale(0.5)', opacity: 0, filter: 'blur(10px)' },
32 | { delay: stagger(0.05, { from: 'last' }), at: '<' },
33 | ],
34 | ['nav', { x: '100%' }, { at: '-0.3' }],
35 | ]
36 | // @ts-expect-error - TODO: Fix this
37 | animate([...menuAnimations])
38 | }, [animate, isOpen])
39 |
40 | return scope
41 | }
42 |
43 | export default function Menu() {
44 | const pathname = usePathname()
45 |
46 | const [isOpen, setIsOpen] = useState(false)
47 |
48 | function toggle() {
49 | setIsOpen(!isOpen)
50 | document.documentElement.classList.toggle('contain')
51 | }
52 |
53 | function close() {
54 | setIsOpen(false)
55 | document.documentElement.classList.remove('contain')
56 | }
57 |
58 | const scope = useMenuAnimation(isOpen)
59 |
60 | useEffect(() => {
61 | if (pathname !== '/projects/*') {
62 | const body = document.querySelector('body')
63 | if (body) {
64 | body.classList.remove('no-mix-blend')
65 | }
66 | }
67 | }, [pathname])
68 |
69 | return (
70 | <>
71 |
77 |
84 | Ryan
85 |
86 |
91 |
92 |
102 | Menu
103 |
104 |
114 | Close
115 |
116 |
117 |
118 |
207 |
208 | >
209 | )
210 | }
211 |
--------------------------------------------------------------------------------
/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useTheme } from 'next-themes'
4 |
5 | interface ModeToggleProps {
6 | blend?: boolean
7 | }
8 |
9 | export function ModeToggle({ blend }: ModeToggleProps) {
10 | const { setTheme, theme } = useTheme()
11 |
12 | return (
13 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/components/preview.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRef, useState } from 'react'
4 | import {
5 | motion,
6 | useMotionValueEvent,
7 | useScroll,
8 | useTransform,
9 | } from 'framer-motion'
10 | import ContentfulImage from '../lib/contentful-image'
11 | import Link from 'next/link'
12 | import { useRouter } from 'next/navigation'
13 | import { useWindowSize } from '@uidotdev/usehooks'
14 | import { ArrowRight } from 'lucide-react'
15 |
16 | export default function Preview({
17 | heading,
18 | url,
19 | image,
20 | }: {
21 | heading: string
22 | url: string
23 | image: any
24 | }) {
25 | // Setup scroll watcher
26 | const ref = useRef(null)
27 | const { scrollYProgress } = useScroll({
28 | target: ref,
29 | offset: ['0 1', '1 1'],
30 | })
31 |
32 | const { scrollYProgress: pageScrollProgress } = useScroll()
33 |
34 | //Navigate to the next project once scrolled to the bottom (only on desktop screen sizes)
35 | /*
36 | const router = useRouter()
37 | const screen = useWindowSize()
38 |
39 | function handleLinkNavigation(url: string) {
40 | router.push(url)
41 | const body = document.querySelector('body')
42 | body?.classList.add('no-scroll')
43 | setTimeout(() => {
44 | body?.classList.remove('no-scroll')
45 | }, 500)
46 | }
47 |
48 | useMotionValueEvent(pageScrollProgress, 'change', (latest) => {
49 | latest >= 0.999 &&
50 | screen?.width &&
51 | screen.width >= 768 &&
52 | handleLinkNavigation(url)
53 | })
54 | */
55 |
56 | // Prefetch the next project (only on desktop screen sizes)
57 | const router = useRouter()
58 | const screen = useWindowSize()
59 |
60 | function handlePrefetch(url: string) {
61 | router.prefetch(url)
62 | }
63 | const [isPrefetched, setIsPrefetched] = useState(false)
64 | useMotionValueEvent(scrollYProgress, 'change', (latest) => {
65 | if (
66 | latest >= 0.1 &&
67 | screen?.width &&
68 | screen.width >= 768 &&
69 | !isPrefetched
70 | ) {
71 | handlePrefetch(url)
72 | setIsPrefetched(true)
73 | }
74 | })
75 |
76 | // Subtle visual effect on scroll
77 | const slide = useTransform(scrollYProgress, [0, 0.6], ['0%', '-100%'])
78 | const slide2 = useTransform(scrollYProgress, [0, 0.6], ['200%', '-100%'])
79 |
80 | return (
81 | <>
82 |
83 |
84 |
85 |
86 | Next up
87 |
88 |
89 | Click to view
90 |
91 |
92 |
93 |
94 | {heading}
95 |
100 |
101 |
102 |
103 |
104 |
109 |
110 |
111 |
121 |
122 |
123 | >
124 | )
125 | }
126 |
--------------------------------------------------------------------------------
/components/project-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import ContentfulImage from '@/lib/contentful-image'
4 | import { motion } from 'framer-motion'
5 | import Link from 'next/link'
6 | import { AspectRatio } from './ui/aspect-ratio'
7 |
8 | export default function ProjectCard({
9 | title,
10 | slug,
11 | screenshot,
12 | role,
13 | className,
14 | style,
15 | }: {
16 | title?: string
17 | slug?: string
18 | screenshot?: any
19 | role?: string
20 | className?: any
21 | style?: any
22 | }) {
23 | const defaultVariants = {
24 | hidden: { opacity: 0, y: '110%' },
25 | visible: {
26 | opacity: 1,
27 | y: 0,
28 | transition: {
29 | duration: 0.5,
30 | ease: [0.2, 0.65, 0.3, 0.9],
31 | },
32 | },
33 | }
34 |
35 | return (
36 |
44 |
45 |
49 |
53 |
54 |
63 |
64 |
65 |
66 |
67 |
{title}
68 |
69 | {role}
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/components/project-list.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useState, useRef } from 'react'
4 | import { motion, useInView } from 'framer-motion'
5 | import AnimatedText from './animated-text'
6 | import ProjectCard from './project-card'
7 |
8 | export default function ProjectList({
9 | heading,
10 | subheading,
11 | items,
12 | }: {
13 | heading: string
14 | subheading?: string
15 | items: any
16 | }) {
17 | //const defaultItems = items.filter((item: any) => item.category === 'Client')
18 | const defaultItems = items
19 | const [projects, setProjects] = useState(defaultItems)
20 | const [selected, setSelected] = useState('All')
21 |
22 | const filter = (value: any) => {
23 | setSelected(value)
24 | setProjects(items.filter((project: any) => project.category === value))
25 |
26 | value != 'All'
27 | ? setProjects(items.filter((project: any) => project.category === value))
28 | : setProjects(items)
29 | }
30 |
31 | const categories = [
32 | ...new Set(
33 | items
34 | .map((item: any) => {
35 | return item.category
36 | })
37 | .sort()
38 | ),
39 | ]
40 |
41 | const defaultVariants = {
42 | hidden: { opacity: 0, y: '110%' },
43 | visible: {
44 | opacity: 1,
45 | y: 0,
46 | transition: {
47 | duration: 0.5,
48 | ease: [0.2, 0.65, 0.3, 0.9],
49 | },
50 | },
51 | }
52 |
53 | const ref = useRef(null)
54 | const isInView = useInView(ref, { amount: 0.5, once: true })
55 |
56 | return (
57 |
58 |
59 |
60 |
61 |
62 |
71 | {projects.length}
72 |
73 |
74 |
75 | {subheading && (
76 |
79 | )}
80 |
87 | filter('All')}
92 | variants={defaultVariants}
93 | aria-label="Show everything"
94 | >
95 | Everything
96 |
97 | {categories.map((category: any, index) => (
98 | filter(category)}
103 | key={index}
104 | variants={defaultVariants}
105 | aria-label={`Show ${category} projects`}
106 | >
107 | {category}
108 |
109 | ))}
110 |
111 |
112 |
113 | {projects.map((project: any) => (
114 |
119 | ))}
120 |
121 |
122 | )
123 | }
124 |
--------------------------------------------------------------------------------
/components/scroll-behavior.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useEffect } from 'react'
3 |
4 | //Hacky fix for always scrolling to top even on page refresh or back button
5 | export default function ScrollBehavior() {
6 | useEffect(() => {
7 | window.history.scrollRestoration = 'manual'
8 | window.scrollTo(0, 0)
9 | }, [])
10 | return null
11 | }
12 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
4 | import { type ThemeProviderProps } from 'next-themes/dist/types'
5 |
6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7 | return {children}
8 | }
9 |
--------------------------------------------------------------------------------
/components/ui/aspect-ratio.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
4 |
5 | const AspectRatio = AspectRatioPrimitive.Root
6 |
7 | export { AspectRatio }
8 |
--------------------------------------------------------------------------------
/components/work-promo.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useRef } from 'react'
4 | import { motion, useScroll, useTransform, MotionValue } from 'framer-motion'
5 | import Link from 'next/link'
6 | import ContentfulImage from '@/lib/contentful-image'
7 | import { AspectRatio } from './ui/aspect-ratio'
8 | import { ArrowRight } from 'lucide-react'
9 |
10 | export default function WorkPromo({ projects }: { projects?: any }) {
11 | // Animate the columns on scroll
12 | const ref = useRef(null)
13 | const { scrollYProgress } = useScroll({
14 | target: ref,
15 | offset: ['start end', 'end start'],
16 | })
17 |
18 | function useParallax(value: MotionValue, distance: number) {
19 | return useTransform(value, [0, 1], [-distance, distance])
20 | }
21 |
22 | const y = useParallax(scrollYProgress, 125)
23 | const y2 = useParallax(scrollYProgress, 350)
24 | const y3 = useParallax(scrollYProgress, 250)
25 |
26 | // Control grid animation on button hover
27 | const [mutedGrid, setMutedGrid] = useState(false)
28 | const gridVariants = {
29 | muted: {
30 | opacity: 0.75,
31 | transition: {
32 | duration: 0.5,
33 | },
34 | },
35 | regular: {
36 | opacity: 1,
37 | transition: {
38 | duration: 0.5,
39 | },
40 | },
41 | }
42 |
43 | return (
44 |
45 |
51 |
55 | {projects.slice(0, 5).map((project: any) => (
56 |
69 | ))}
70 |
71 |
75 | {projects.slice(5, 10).map((project: any) => (
76 |
89 | ))}
90 |
91 |
95 | {projects.slice(10, 15).map((project: any) => (
96 |
109 | ))}
110 |
111 |
112 |
113 | setMutedGrid(true)}
115 | onHoverEnd={() => setMutedGrid(false)}
116 | whileHover={{ scale: 1.03, transition: { duration: 0.5 } }}
117 | className="group"
118 | >
119 |
123 | Explore work
124 |
128 |
129 |
130 |
131 |
132 | )
133 | }
134 |
--------------------------------------------------------------------------------
/lib/api.ts:
--------------------------------------------------------------------------------
1 | const PROJECT_GRAPHQL_FIELDS = `
2 | slug
3 | title
4 | heading
5 | description
6 | cover {
7 | url
8 | title
9 | description
10 | }
11 | screenshot {
12 | url
13 | title
14 | description
15 | }
16 | date
17 | content {
18 | json
19 | }
20 | role
21 | client
22 | category
23 | year
24 | technology
25 | url
26 | imagesCollection {
27 | items {
28 | url
29 | title
30 | description
31 | }
32 | }
33 | `
34 |
35 | const PAGE_GRAPHQL_FIELDS = `
36 | slug
37 | title
38 | description
39 | heading
40 | subheading
41 | cover {
42 | url
43 | title
44 | description
45 | }
46 | videosCollection {
47 | items {
48 | url
49 | title
50 | description
51 | }
52 | }
53 | imagesCollection {
54 | items {
55 | url
56 | title
57 | description
58 | }
59 | }
60 | content {
61 | json
62 | links {
63 | assets {
64 | block {
65 | sys {
66 | id
67 | }
68 | title
69 | url
70 | description
71 | }
72 | }
73 | }
74 | }
75 | `
76 |
77 | async function fetchGraphQL(query: string, preview = false): Promise {
78 | return fetch(
79 | `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}/environments/${process.env.CONTENTFUL_ENVIRONMENT}`,
80 | {
81 | method: 'POST',
82 | headers: {
83 | 'Content-Type': 'application/json',
84 | Authorization: `Bearer ${
85 | preview
86 | ? process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN
87 | : process.env.CONTENTFUL_ACCESS_TOKEN
88 | }`,
89 | },
90 | body: JSON.stringify({ query }),
91 | next: { tags: ['contentfulData'] },
92 | }
93 | ).then((response) => response.json())
94 | }
95 |
96 | //Projects
97 | function extractProject(fetchResponse: any): any {
98 | return fetchResponse?.data?.projectCollection?.items?.[0]
99 | }
100 |
101 | function extractProjectEntries(fetchResponse: any): any[] {
102 | return fetchResponse?.data?.projectCollection?.items
103 | }
104 |
105 | export async function getPreviewProjectBySlug(
106 | slug: string | null
107 | ): Promise {
108 | const entry = await fetchGraphQL(
109 | `query {
110 | projectCollection(where: { slug: "${slug}" }, preview: true, limit: 1) {
111 | items {
112 | ${PROJECT_GRAPHQL_FIELDS}
113 | }
114 | }
115 | }`,
116 | true
117 | )
118 | return extractProject(entry)
119 | }
120 |
121 | export async function getAllProjects(isDraftMode: boolean): Promise {
122 | const entries = await fetchGraphQL(
123 | `query {
124 | projectCollection(where: { slug_exists: true }, order: date_DESC, preview: ${
125 | isDraftMode ? 'true' : 'false'
126 | }) {
127 | items {
128 | ${PROJECT_GRAPHQL_FIELDS}
129 | }
130 | }
131 | }`,
132 | isDraftMode
133 | )
134 | return extractProjectEntries(entries)
135 | }
136 |
137 | export async function getProjectAndNextProject(
138 | slug: string,
139 | preview: boolean
140 | ): Promise {
141 | const project = await fetchGraphQL(
142 | `query {
143 | projectCollection(where: { slug: "${slug}" }, preview: ${
144 | preview ? 'true' : 'false'
145 | }, limit: 1) {
146 | items {
147 | ${PROJECT_GRAPHQL_FIELDS}
148 | }
149 | }
150 | }`,
151 | preview
152 | )
153 |
154 | const allProjects = await fetchGraphQL(
155 | `query {
156 | projectCollection(where: { slug_exists: true }, order: date_DESC, preview: ${
157 | preview ? 'true' : 'false'
158 | }) {
159 | items {
160 | ${PROJECT_GRAPHQL_FIELDS}
161 | }
162 | }
163 | }`,
164 | preview
165 | )
166 |
167 | const allProjectEntries = extractProjectEntries(allProjects)
168 | const currentProjectDate = new Date(extractProject(project).date)
169 | const nextProject =
170 | allProjectEntries
171 | .filter((project) => new Date(project.date) < currentProjectDate)
172 | .sort((a, b) => +new Date(b.date) - +new Date(a.date))[0] || null
173 | return {
174 | project: extractProject(project),
175 | nextProject,
176 | }
177 | }
178 |
179 | // Get Page by Slug
180 | function extractPage(fetchResponse: any): any {
181 | return fetchResponse?.data?.pageCollection?.items?.[0]
182 | }
183 |
184 | export async function getPreviewPageBySlug(slug: string | null): Promise {
185 | const entry = await fetchGraphQL(
186 | `query {
187 | pageCollection(where: { slug: "${slug}" }, preview: true, limit: 1) {
188 | items {
189 | ${PAGE_GRAPHQL_FIELDS}
190 | }
191 | }
192 | }`,
193 | true
194 | )
195 | return extractPage(entry)
196 | }
197 |
198 | export async function getPageBySlug(
199 | slug: string,
200 | preview: boolean
201 | ): Promise {
202 | const entry = await fetchGraphQL(
203 | `query {
204 | pageCollection(where: { slug: "${slug}" }, preview: ${
205 | preview ? 'true' : 'false'
206 | }, limit: 1) {
207 | items {
208 | ${PAGE_GRAPHQL_FIELDS}
209 | }
210 | }
211 | }`,
212 | preview
213 | )
214 | return extractPage(entry)
215 | }
216 |
--------------------------------------------------------------------------------
/lib/contentful-image.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Image from 'next/image'
4 |
5 | interface ContentfulImageProps {
6 | src: string
7 | width?: number
8 | quality?: number
9 | [key: string]: any // For other props that might be passed
10 | }
11 |
12 | const contentfulLoader = ({ src, width, quality }: ContentfulImageProps) => {
13 | return `${src}?w=${width}&q=${quality || 75}`
14 | }
15 |
16 | export default function ContentfulImage(props: ContentfulImageProps) {
17 | return
18 | }
19 |
--------------------------------------------------------------------------------
/lib/markdown.tsx:
--------------------------------------------------------------------------------
1 | import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
2 | import { BLOCKS, MARKS, INLINES } from '@contentful/rich-text-types'
3 | import ContentfulImage from './contentful-image'
4 | import { FileText } from 'lucide-react'
5 | import { ArrowUpRight } from 'lucide-react'
6 |
7 | interface Asset {
8 | sys: {
9 | id: string
10 | }
11 | url: string
12 | description: string
13 | }
14 |
15 | interface AssetLink {
16 | block: Asset[]
17 | }
18 |
19 | interface Content {
20 | json: any
21 | links: {
22 | assets: AssetLink
23 | }
24 | }
25 |
26 | function RichTextAsset({
27 | id,
28 | assets,
29 | }: {
30 | id: string
31 | assets: Asset[] | undefined
32 | }) {
33 | const asset = assets?.find((asset) => asset.sys.id === id)
34 |
35 | if (asset?.url) {
36 | if (asset.url.endsWith('.pdf')) {
37 | // PDF Asset
38 | return (
39 |
45 |
46 |
50 |
51 | {asset.description}
52 |
55 |
56 | )
57 | } else {
58 | // Regular Image
59 | const isLeft = asset.description?.includes('left')
60 | const isRight = asset.description?.includes('right')
61 | const isFull = asset.description?.includes('full')
62 | const divClass = `rounded-xl mb-4 mx-auto clear-right ${
63 | isLeft
64 | ? 'w-[calc(50%-.5rem)] float-left'
65 | : isRight
66 | ? 'w-[calc(50%-.5rem)] float-right clear-right'
67 | : isFull
68 | ? ''
69 | : 'w-full'
70 | }`
71 |
72 | return (
73 |
74 |
81 |
82 | )
83 | }
84 | }
85 |
86 | return null
87 | }
88 |
89 | export function Markdown({ content }: { content: Content }) {
90 | return documentToReactComponents(content.json, {
91 | renderText: (text) => {
92 | return text.split('\n').reduce((children: any, textSegment, index) => {
93 | return [...children, index > 0 &&
, textSegment]
94 | }, [])
95 | },
96 | renderMark: {
97 | [MARKS.BOLD]: (text) => {text},
98 | [MARKS.CODE]: (text) => (
99 | {text}
100 | ),
101 | [MARKS.ITALIC]: (text) => {text},
102 | [MARKS.UNDERLINE]: (text) => {text},
103 | },
104 |
105 | renderNode: {
106 | [BLOCKS.EMBEDDED_ASSET]: (node: any) => (
107 |
111 | ),
112 | [BLOCKS.PARAGRAPH]: (_, children: any) => (
113 | {children}
114 | ),
115 | [BLOCKS.HEADING_1]: (_, children: any) => (
116 |
117 | {children}
118 |
119 | ),
120 | [BLOCKS.HEADING_2]: (_, children: any) => (
121 |
122 | {children}
123 |
124 | ),
125 | [BLOCKS.HEADING_3]: (_, children: any) => (
126 |
127 | {children}
128 |
129 | ),
130 | [BLOCKS.HEADING_4]: (_, children: any) => (
131 |
132 | {children}
133 |
134 | ),
135 | [BLOCKS.HEADING_5]: (_, children: any) => (
136 |
137 | {children}
138 |
139 | ),
140 | [BLOCKS.HEADING_6]: (_, children: any) => (
141 |
142 | {children}
143 |
144 | ),
145 | [BLOCKS.UL_LIST]: (_, children: any) => (
146 |
147 | ),
148 | [BLOCKS.OL_LIST]: (_, children: any) => (
149 | {children}
150 | ),
151 | [BLOCKS.LIST_ITEM]: (_, children: any) => (
152 | {children}
153 | ),
154 | [BLOCKS.QUOTE]: (_, children: any) => (
155 |
156 | {children}
157 |
158 | ),
159 | [BLOCKS.HR]: (_) => ,
160 | [INLINES.HYPERLINK]: (node: any, children: any) => (
161 |
167 | {children}
168 |
169 | ),
170 | },
171 | })
172 | }
173 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next-sitemap').IConfig} */
2 |
3 | const stagePolicies = [
4 | {
5 | userAgent: '*',
6 | disallow: '/',
7 | },
8 | ]
9 |
10 | const prodPolicies = [
11 | {
12 | userAgent: '*',
13 | allow: '/',
14 | },
15 | ]
16 |
17 | module.exports = {
18 | siteUrl: process.env.SITE_URL || 'https://www.ryanwiemer.com',
19 | generateIndexSitemap: false,
20 | generateRobotsTxt: true,
21 | robotsTxtOptions: {
22 | policies:
23 | process.env.CONTENTFUL_ENVIRONMENT === 'stage' ||
24 | process.env.CONTENTFUL_ENVIRONMENT === 'dev'
25 | ? stagePolicies
26 | : prodPolicies,
27 | },
28 | }
29 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 |
3 | import withPlaiceholder from '@plaiceholder/next'
4 |
5 | const nextConfig = {
6 | images: {
7 | loader: 'custom',
8 | formats: ['image/avif', 'image/webp'],
9 | },
10 | }
11 |
12 | export default withPlaiceholder(nextConfig)
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rw",
3 | "description": "Ryan Wiemer's online portfolio",
4 | "license": "UNLICENSED",
5 | "version": "3.0.4",
6 | "author": "Ryan Wiemer ",
7 | "keywords": [
8 | "rw"
9 | ],
10 | "repository": "https://github.com/ryanwiemer/rw",
11 | "homepage": "https://www.ryanwiemer.com/",
12 | "scripts": {
13 | "dev": "next dev",
14 | "debug": "NODE_OPTIONS='--inspect' next dev",
15 | "build": "next build",
16 | "postbuild": "next-sitemap",
17 | "start": "next start",
18 | "lint": "next lint ."
19 | },
20 | "dependencies": {
21 | "@contentful/rich-text-react-renderer": "^15.22.1",
22 | "@contentful/rich-text-types": "^16.6.1",
23 | "@plaiceholder/next": "^3.0.0",
24 | "@radix-ui/react-aspect-ratio": "^1.1.0",
25 | "@tailwindcss/typography": "0.5.13",
26 | "@types/node": "^20.14.10",
27 | "@types/react": "^18.3.3",
28 | "@types/react-dom": "^18.3.0",
29 | "@uidotdev/usehooks": "^2.4.1",
30 | "@vercel/analytics": "^1.3.1",
31 | "@vercel/speed-insights": "^1.0.12",
32 | "autoprefixer": "10.4.19",
33 | "class-variance-authority": "^0.7.0",
34 | "clsx": "^2.1.1",
35 | "date-fns": "3.6.0",
36 | "framer-motion": "^11.2.13",
37 | "lucide-react": "^0.401.0",
38 | "next": "14.2.4",
39 | "next-sitemap": "^4.2.3",
40 | "next-themes": "^0.3.0",
41 | "plaiceholder": "^3.0.0",
42 | "postcss": "8.4.39",
43 | "react": "^18.3.1",
44 | "react-dom": "^18.3.1",
45 | "sharp": "0.32.6",
46 | "tailwind-gradient-mask-image": "^1.2.0",
47 | "tailwind-merge": "^2.4.0",
48 | "tailwindcss": "^3.4.4",
49 | "tailwindcss-animate": "^1.0.7",
50 | "tailwindcss-inner-border": "^0.2.0",
51 | "typescript": "^5.5.3"
52 | },
53 | "devDependencies": {
54 | "@typescript-eslint/eslint-plugin": "^7.15.0",
55 | "eslint": "^9.6.0",
56 | "eslint-config-next": "^14.2.4",
57 | "eslint-config-prettier": "^9.1.0",
58 | "prettier": "^3.3.2"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import typography from '@tailwindcss/typography'
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | future: {
6 | hoverOnlyWhenSupported: true,
7 | },
8 | darkMode: ['class'],
9 | content: [
10 | './pages/**/*.{ts,tsx}',
11 | './components/**/*.{ts,tsx}',
12 | './app/**/*.{ts,tsx}',
13 | './src/**/*.{ts,tsx}',
14 | './lib/**/*.{ts,tsx}',
15 | ],
16 | theme: {
17 | screens: {
18 | sm: '480px',
19 | md: '768px',
20 | lg: '976px',
21 | xl: '1440px',
22 | },
23 | container: {
24 | center: true,
25 | padding: '1.4rem',
26 | screens: {
27 | '2xl': '1400px',
28 | },
29 | },
30 | extend: {
31 | fontFamily: {
32 | sans: ['var(--font-inter)'],
33 | },
34 | colors: {
35 | border: 'hsl(var(--border))',
36 | input: 'hsl(var(--input))',
37 | ring: 'hsl(var(--ring))',
38 | background: 'hsl(var(--background))',
39 | foreground: 'hsl(var(--foreground))',
40 | primary: {
41 | DEFAULT: 'hsl(var(--primary))',
42 | foreground: 'hsl(var(--primary-foreground))',
43 | },
44 | secondary: {
45 | DEFAULT: 'hsl(var(--secondary))',
46 | foreground: 'hsl(var(--secondary-foreground))',
47 | },
48 | destructive: {
49 | DEFAULT: 'hsl(var(--destructive))',
50 | foreground: 'hsl(var(--destructive-foreground))',
51 | },
52 | muted: {
53 | DEFAULT: 'hsl(var(--muted))',
54 | foreground: 'hsl(var(--muted-foreground))',
55 | },
56 | accent: {
57 | DEFAULT: 'hsl(var(--accent))',
58 | foreground: 'hsl(var(--accent-foreground))',
59 | },
60 | popover: {
61 | DEFAULT: 'hsl(var(--popover))',
62 | foreground: 'hsl(var(--popover-foreground))',
63 | },
64 | card: {
65 | DEFAULT: 'hsl(var(--card))',
66 | foreground: 'hsl(var(--card-foreground))',
67 | },
68 | },
69 | borderRadius: {
70 | lg: 'var(--radius)',
71 | md: 'calc(var(--radius) - 2px)',
72 | sm: 'calc(var(--radius) - 4px)',
73 | },
74 | keyframes: {
75 | 'accordion-down': {
76 | from: { height: 0 },
77 | to: { height: 'var(--radix-accordion-content-height)' },
78 | },
79 | 'accordion-up': {
80 | from: { height: 'var(--radix-accordion-content-height)' },
81 | to: { height: 0 },
82 | },
83 | },
84 | animation: {
85 | 'accordion-down': 'accordion-down 0.2s ease-out',
86 | 'accordion-up': 'accordion-up 0.2s ease-out',
87 | },
88 | },
89 | },
90 | plugins: [
91 | typography,
92 | require('tailwind-gradient-mask-image'),
93 | require('tailwindcss-animate'),
94 | require('tailwindcss-inner-border'),
95 | ],
96 | }
97 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------