├── .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 |
44 |
45 | 46 |
47 |
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 |
24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 | 36 |
37 |
38 |
39 |
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 |