Migrated to nextjs13 & TypeScript

This commit is contained in:
r-freeman 2023-01-14 19:31:05 +00:00
parent 8edf916fba
commit 2a11b29962
48 changed files with 4245 additions and 2884 deletions

12
.gitignore vendored
View File

@ -1,5 +1,11 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea/
public/rss
public/sitemap.xml
public/robots.txt
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
@ -31,6 +37,6 @@ yarn-error.log*
# vercel # vercel
.vercel .vercel
# generated files # typescript
/public/rss/ *.tsbuildinfo
.idea/ next-env.d.ts

View File

@ -3,9 +3,14 @@
This website was built using React, Next.js and Tailwind CSS. It is designed to showcase my professional experience and skills, as This website was built using React, Next.js and Tailwind CSS. It is designed to showcase my professional experience and skills, as
well as provide information about me and my interests. well as provide information about me and my interests.
In addition to functioning as a personal blog, this website shows what I'm listening to on Spotify. This ## Features
is a cool way to give visitors an insight into my personal music preferences and adds a dynamic element to the website. I hope to
add additional features and content to my portfolio website in the future.
Feel free to explore the different sections, and contact me if you have any questions or would like to work - 📝 TypeScript.
together. - 💨 Styled with Tailwind CSS.
- 🌙Toggleable dark/light mode.
- ✍️ Personal blog with MDX (JSX in Markdown).
- 🎵 Shows what I'm listening to on Spotify.
- 🤖 Automated RSS feed and sitemap generation.
Additional features will be added in the future. Feel free to explore the different sections, and contact me if you have any
questions or would like to work together.

BIN
images/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

View File

@ -2,7 +2,9 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": [
"src/*"
]
} }
} }
} }

View File

@ -1,6 +0,0 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com',
generateRobotsTxt: true,
generateIndexSitemap: false
}

View File

@ -4,12 +4,12 @@ import rehypePrism from '@mapbox/rehype-prism'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
pageExtensions: ['jsx', 'js', 'mdx'], pageExtensions: ['jsx', 'js', 'tsx', 'mdx'],
reactStrictMode: true, reactStrictMode: true,
swcMinify: true, swcMinify: true,
experimental: { experimental: {
newNextLinkBehavior: true, newNextLinkBehavior: true,
scrollRestoration: true, scrollRestoration: true
}, },
images: { images: {
domains: ['i.scdn.co'] domains: ['i.scdn.co']
@ -29,7 +29,7 @@ const withMDX = nextMDX({
options: { options: {
remarkPlugins: [remarkGfm], remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrism], rehypePlugins: [rehypePrism],
}, }
}) })
export default withMDX(nextConfig) export default withMDX(nextConfig)

5988
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,42 @@
{ {
"name": "tailwindui-template", "name": "portfolio",
"author": "Ryan Freeman",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build && next-sitemap", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"
}, },
"browserslist": "defaults, not ie <= 11",
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.0", "@headlessui/react": "^1.7.7",
"@mapbox/rehype-prism": "^0.8.0", "@mapbox/rehype-prism": "^0.8.0",
"@mdx-js/loader": "^2.1.2", "@next/mdx": "^13.1.1",
"@next/mdx": "^12.2.3", "@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.4", "@tailwindcss/typography": "^0.5.8",
"autoprefixer": "^10.4.7", "@types/mdx": "^2.0.3",
"clsx": "^1.2.0", "@types/node": "18.11.18",
"fast-glob": "^3.2.11", "@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"autoprefixer": "^10.4.13",
"clsx": "^1.2.1",
"eslint": "8.31.0",
"eslint-config-next": "13.1.1",
"fast-glob": "^3.2.12",
"feed": "^4.2.2", "feed": "^4.2.2",
"focus-visible": "^5.2.0", "focus-visible": "^5.2.0",
"motion": "^10.15.3", "motion": "^10.15.5",
"next": "^12.3.0", "next": "13.1.2",
"next-sitemap": "^3.1.44",
"node-fetch": "^3.3.0", "node-fetch": "^3.3.0",
"postcss-focus-visible": "^6.0.4", "postcss": "^8.4.21",
"postcss-focus-visible": "^7.1.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sharp": "^0.31.3",
"swr": "^2.0.0", "swr": "^2.0.0",
"tailwindcss": "^3.1.4" "tailwindcss": "^3.2.4",
}, "typescript": "4.9.4"
"devDependencies": {
"@tailwindcss/line-clamp": "^0.4.2",
"eslint": "8.19.0",
"eslint-config-next": "12.2.5",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.11"
} }
} }

View File

@ -5,5 +5,5 @@ module.exports = {
replaceWith: '[data-focus-visible-added]', replaceWith: '[data-focus-visible-added]',
}, },
autoprefixer: {}, autoprefixer: {},
}, }
} }

View File

@ -1,5 +0,0 @@
module.exports = {
singleQuote: true,
semi: false,
plugins: [require('prettier-plugin-tailwindcss')],
}

View File

@ -1,3 +0,0 @@
User-Agent: *
Allow: /
Sitemap: https://ryanfreeman.dev/sitemap.xml

View File

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ryanfreeman.dev/</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>1.00</priority>
</url>
<url>
<loc>https://ryanfreeman.dev/about</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://ryanfreeman.dev/writing</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://ryanfreeman.dev/projects</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://ryanfreeman.dev/uses</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://ryanfreeman.dev/writing/how-to-add-typescript-to-an-existing-nextjs-project</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://ryanfreeman.dev/writing/a-personal-journey-in-software-engineering</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://ryanfreeman.dev/Ryan%20Freeman%20CV.pdf</loc>
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
<priority>0.80</priority>
</url>
</urlset>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

View File

@ -1,30 +1,27 @@
import Head from 'next/head' import Head from 'next/head'
import {useRouter} from 'next/router' import {usePathname} from 'next/navigation'
import {Container} from './Container'
import {Container} from '@/components/Container'
import {formatDate} from '@/lib/formatDate' import {formatDate} from '@/lib/formatDate'
import {Prose} from '@/components/Prose' import {Prose} from './Prose'
import {ReactNode} from 'react'
function ArrowLeftIcon(props) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path
d="M7.25 11.25 3.75 8m0 0 3.5-3.25M3.75 8h8.5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export function ArticleLayout({ export function ArticleLayout({
children, children,
meta,
isRssFeed = false, isRssFeed = false,
previousPathname, title,
description,
ogImage,
date
}:
{
children?: ReactNode,
isRssFeed: boolean,
title: string,
description: string,
ogImage: string,
date: string
}) { }) {
let router = useRouter() const pathname = usePathname()
if (isRssFeed) { if (isRssFeed) {
return children return children
@ -33,11 +30,11 @@ export function ArticleLayout({
return ( return (
<> <>
<Head> <Head>
<title>{`${meta.title} - Ryan Freeman`}</title> <title>{`${title} - Ryan Freeman`}</title>
<meta name="description" content={meta.description}/> <meta name="description" content={description}/>
<meta <meta
property="og:url" property="og:url"
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`} content={`${process.env.NEXT_PUBLIC_SITE_URL}${pathname}`}
/> />
<meta <meta
property="og:type" property="og:type"
@ -45,17 +42,17 @@ export function ArticleLayout({
/> />
<meta <meta
property="og:title" property="og:title"
content={meta.title} content={title}
/> />
<meta <meta
property="og:description" property="og:description"
content={meta.description} content={description}
/> />
{meta.ogImage && {ogImage &&
<> <>
<meta <meta
property="og:image" property="og:image"
content={meta.ogImage} content={ogImage}
/> />
<meta <meta
name="twitter:card" name="twitter:card"
@ -63,7 +60,7 @@ export function ArticleLayout({
/> />
<meta <meta
name="twitter:image" name="twitter:image"
content={meta.ogImage} content={ogImage}
/> />
</> </>
} }
@ -73,41 +70,30 @@ export function ArticleLayout({
/> />
<meta <meta
property="twitter:url" property="twitter:url"
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`} content={`${process.env.NEXT_PUBLIC_SITE_URL}${pathname}`}
/> />
<meta <meta
name="twitter:title" name="twitter:title"
content={meta.title} content={title}
/> />
<meta <meta
name="twitter:description" name="twitter:description"
content={meta.description} content={description}
/> />
</Head> </Head>
<Container className="mt-16 lg:mt-32"> <Container className="mt-16 lg:mt-32">
<div className="xl:relative"> <div className="xl:relative">
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-2xl">
{previousPathname && (
<button
type="button"
onClick={() => router.back()}
aria-label="Go back to articles"
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:mb-0 lg:-mt-2 xl:-top-1.5 xl:left-0 xl:mt-0"
>
<ArrowLeftIcon
className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400"/>
</button>
)}
<article> <article>
<header className="flex flex-col"> <header className="flex flex-col">
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl"> <h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
{meta.title} {title}
</h1> </h1>
<time <time
dateTime={meta.date} dateTime={date}
className="order-first flex items-center text-base text-zinc-500 dark:text-zinc-400" className="order-first flex items-center text-base text-zinc-500 dark:text-zinc-400"
> >
<span>{formatDate(meta.date)}</span> <span>{formatDate(date)}</span>
</time> </time>
</header> </header>
<Prose className="mt-8">{children}</Prose> <Prose className="mt-8">{children}</Prose>

View File

@ -1,23 +0,0 @@
import Link from 'next/link'
import clsx from 'clsx'
const variantStyles = {
primary:
'bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70',
secondary:
'bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-indigo-700 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70',
}
export function Button({ variant = 'primary', className, href, ...props }) {
className = clsx(
'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
variantStyles[variant],
className
)
return href ? (
<Link href={href} className={className} {...props} />
) : (
<button className={className} {...props} />
)
}

36
src/components/Button.tsx Normal file
View File

@ -0,0 +1,36 @@
import Link from 'next/link'
import clsx from 'clsx'
import {ReactNode} from 'react'
type VariantStyles = {
primary: string
secondary: string
}
type Button = {
variant?: string
className: string
href: string
children: ReactNode
}
const variantStyles: VariantStyles = {
primary:
'bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70',
secondary:
'bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-indigo-700 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70',
}
export function Button({variant = 'primary', className, href, ...props}: Button) {
className = clsx(
'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
variantStyles[variant as keyof VariantStyles],
className
)
return href ? (
<Link href={href} className={className} {...props} />
) : (
<button className={className} {...props} />
)
}

View File

@ -1,7 +1,43 @@
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
import {ElementType, ReactNode} from 'react'
import {Props} from 'types'
function ChevronRightIcon(props) { type Card = {
as?: ElementType
className?: string
small?: boolean
children: ReactNode
}
type CardLink = {
href: string
children: ReactNode
}
type CardTitle = {
as?: ElementType
href: string
children: ReactNode
}
type CardDescription = {
children: ReactNode
}
type CardCta = {
children: ReactNode
}
type CardEyebrow = {
as: ElementType
dateTime: string
decorate: boolean
className?: string
children: ReactNode
}
function ChevronRightIcon(props: Props) {
return ( return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path <path
@ -14,7 +50,12 @@ function ChevronRightIcon(props) {
) )
} }
export function Card({as: Component = 'div', className, small, children}) { export function Card({
as: Component = 'div',
className,
small,
children
}: Card) {
return ( return (
<Component <Component
className={clsx(className, 'group relative flex flex-col items-baseline', small && 'md:flex-row justify-between')} className={clsx(className, 'group relative flex flex-col items-baseline', small && 'md:flex-row justify-between')}
@ -24,12 +65,12 @@ export function Card({as: Component = 'div', className, small, children}) {
) )
} }
Card.Link = function CardLink({children, ...props}) { Card.Link = function CardLink({href, children}: CardLink) {
return ( return (
<> <>
<div <div
className="absolute -inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl"/> className="absolute -inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl"/>
<Link {...props}> <Link href={href}>
<span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl"/> <span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl"/>
<span className="relative z-10">{children}</span> <span className="relative z-10">{children}</span>
</Link> </Link>
@ -37,7 +78,7 @@ Card.Link = function CardLink({children, ...props}) {
) )
} }
Card.Title = function CardTitle({as: Component = 'h2', href, children}) { Card.Title = function CardTitle({as: Component = 'h2', href, children}: CardTitle) {
return ( return (
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100"> <Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{href ? <Card.Link href={href}>{children}</Card.Link> : children} {href ? <Card.Link href={href}>{children}</Card.Link> : children}
@ -45,7 +86,7 @@ Card.Title = function CardTitle({as: Component = 'h2', href, children}) {
) )
} }
Card.Description = function CardDescription({children}) { Card.Description = function CardDescription({children}: CardDescription) {
return ( return (
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400"> <p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{children} {children}
@ -53,7 +94,7 @@ Card.Description = function CardDescription({children}) {
) )
} }
Card.Cta = function CardCta({children}) { Card.Cta = function CardCta({children}: CardCta) {
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
@ -71,7 +112,7 @@ Card.Eyebrow = function CardEyebrow({
className, className,
children, children,
...props ...props
}) { }: CardEyebrow) {
return ( return (
<Component <Component
className={clsx( className={clsx(

View File

@ -1,42 +0,0 @@
import { forwardRef } from 'react'
import clsx from 'clsx'
const OuterContainer = forwardRef(function OuterContainer(
{ className, children, ...props },
ref
) {
return (
<div ref={ref} className={clsx('sm:px-8', className)} {...props}>
<div className="mx-auto max-w-7xl lg:px-8">{children}</div>
</div>
)
})
const InnerContainer = forwardRef(function InnerContainer(
{ className, children, ...props },
ref
) {
return (
<div
ref={ref}
className={clsx('relative px-4 sm:px-8 lg:px-12', className)}
{...props}
>
<div className="mx-auto max-w-2xl lg:max-w-5xl">{children}</div>
</div>
)
})
export const Container = forwardRef(function Container(
{ children, ...props },
ref
) {
return (
<OuterContainer ref={ref} {...props}>
<InnerContainer>{children}</InnerContainer>
</OuterContainer>
)
})
Container.Outer = OuterContainer
Container.Inner = InnerContainer

View File

@ -0,0 +1,45 @@
import {forwardRef, ReactNode} from 'react'
import clsx from 'clsx'
type Container = {
className?: string
children: ReactNode
style?: Object
}
export const OuterContainer = forwardRef<HTMLDivElement, Container>(function OuterContainer(
{className, children, ...props},
ref
) {
return (
<div ref={ref} className={clsx('sm:px-8', className)} {...props}>
<div className="mx-auto max-w-7xl lg:px-8">{children}</div>
</div>
)
})
export const InnerContainer = forwardRef<HTMLDivElement, Container>(function InnerContainer(
{className, children, ...props},
ref
) {
return (
<div
ref={ref}
className={clsx('relative px-4 sm:px-8 lg:px-12', className)}
{...props}
>
<div className="mx-auto max-w-2xl lg:max-w-5xl">{children}</div>
</div>
)
})
export const Container = forwardRef<HTMLDivElement, Container>(function Container(
{children, ...props},
ref
) {
return (
<OuterContainer ref={ref} {...props}>
<InnerContainer>{children}</InnerContainer>
</OuterContainer>
)
})

View File

@ -1,9 +1,10 @@
import Link from 'next/link' import Link from 'next/link'
import {ReactNode} from 'react'
import {Container} from '@/components/Container' import {OuterContainer, InnerContainer} from './Container'
import {SpotifyPlayer} from '@/components/SpotifyPlayer' import {SpotifyPlayer} from './SpotifyPlayer'
function NavLink({href, children}) { function NavLink({href, children}: { href: string, children: ReactNode }) {
return ( return (
<Link <Link
href={href} href={href}
@ -17,9 +18,9 @@ function NavLink({href, children}) {
export function Footer() { export function Footer() {
return ( return (
<footer className="mt-32"> <footer className="mt-32">
<Container.Outer> <OuterContainer>
<div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40"> <div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
<Container.Inner> <InnerContainer>
<SpotifyPlayer/> <SpotifyPlayer/>
<div className="flex flex-col items-center justify-between gap-6 mt-12"> <div className="flex flex-col items-center justify-between gap-6 mt-12">
<div className="flex gap-6 text-sm font-medium text-zinc-800 dark:text-zinc-200"> <div className="flex gap-6 text-sm font-medium text-zinc-800 dark:text-zinc-200">
@ -29,9 +30,9 @@ export function Footer() {
<NavLink href="/uses">Uses</NavLink> <NavLink href="/uses">Uses</NavLink>
</div> </div>
</div> </div>
</Container.Inner> </InnerContainer>
</div> </div>
</Container.Outer> </OuterContainer>
</footer> </footer>
) )
} }

View File

@ -1,14 +1,16 @@
import Image from 'next/future/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import {useRouter} from 'next/router' import {usePathname} from 'next/navigation'
import {Fragment, useEffect, useRef} from 'react'
import {Popover, Transition} from '@headlessui/react' import {Popover, Transition} from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import {Container} from '@/components/Container' import {Container} from './Container'
import avatar from '@/images/avatar.jpg' import avatar from '@/images/avatar.jpg'
import {Fragment, useEffect, useRef} from 'react'
function CloseIcon(props) { import type {Props} from 'types'
function CloseIcon(props: Props) {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}> <svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path <path
@ -23,7 +25,7 @@ function CloseIcon(props) {
) )
} }
function ChevronDownIcon(props) { function ChevronDownIcon(props: Props) {
return ( return (
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}> <svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
<path <path
@ -37,7 +39,7 @@ function ChevronDownIcon(props) {
) )
} }
function SunIcon(props) { function SunIcon(props: Props) {
return ( return (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -57,7 +59,7 @@ function SunIcon(props) {
) )
} }
function MoonIcon(props) { function MoonIcon(props: Props) {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}> <svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path <path
@ -70,7 +72,7 @@ function MoonIcon(props) {
) )
} }
function MobileNavItem({href, children}) { function MobileNavItem({href, children}: { href: string } & Props) {
return ( return (
<li> <li>
<Popover.Button as={Link} href={href} className="block py-2"> <Popover.Button as={Link} href={href} className="block py-2">
@ -80,7 +82,7 @@ function MobileNavItem({href, children}) {
) )
} }
function MobileNavigation(props) { function MobileNavigation(props: Props) {
return ( return (
<Popover {...props}> <Popover {...props}>
<Popover.Button <Popover.Button
@ -137,8 +139,8 @@ function MobileNavigation(props) {
) )
} }
function NavItem({href, children}) { function NavItem({href, children}: { href: string } & Props) {
let isActive = useRouter().pathname === href let isActive = usePathname() === href
return ( return (
<li> <li>
@ -161,7 +163,7 @@ function NavItem({href, children}) {
) )
} }
function DesktopNavigation(props) { function DesktopNavigation(props: Props) {
return ( return (
<nav {...props}> <nav {...props}>
<ul className="flex rounded-full bg-white/90 px-3 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10"> <ul className="flex rounded-full bg-white/90 px-3 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10">
@ -211,13 +213,13 @@ function ModeToggle() {
) )
} }
function clamp(number, a, b) { function clamp(num: number, a: number, b: number) {
let min = Math.min(a, b) let min = Math.min(a, b)
let max = Math.max(a, b) let max = Math.max(a, b)
return Math.min(Math.max(number, min), max) return Math.min(Math.max(num, min), max)
} }
function AvatarContainer({className, ...props}) { function AvatarContainer({className, ...props}: { style?: Object } & Props) {
return ( return (
<div <div
className={clsx( className={clsx(
@ -229,7 +231,7 @@ function AvatarContainer({className, ...props}) {
) )
} }
function Avatar({large = false, className, ...props}) { function Avatar({large = false, className, ...props}: { large?: boolean, style?: Object } & Props) {
return ( return (
<Link <Link
href="/" href="/"
@ -253,26 +255,36 @@ function Avatar({large = false, className, ...props}) {
} }
export function Header() { export function Header() {
let isHomePage = useRouter().pathname === '/' const isHomePage = usePathname() === '/'
let headerRef = useRef() const headerRef = useRef<HTMLDivElement>(null)
let avatarRef = useRef() const avatarRef = useRef<HTMLImageElement>(null)
let isInitial = useRef(true) const isInitial = useRef(true)
const headerPosition: Object = {
position: 'var(--header-position)'
}
const headerInnerPosition: Object = {
position: 'var(--header-inner-position)'
}
useEffect(() => { useEffect(() => {
let downDelay = avatarRef.current?.offsetTop ?? 0 let downDelay = avatarRef.current?.offsetTop ?? 0
let upDelay = 64 let upDelay = 64
function setProperty(property, value) { function setProperty(property: string, value: string) {
document.documentElement.style.setProperty(property, value) document.documentElement.style.setProperty(property, value.toString())
} }
function removeProperty(property) { function removeProperty(property: string) {
document.documentElement.style.removeProperty(property) document.documentElement.style.removeProperty(property)
} }
function updateHeaderStyles() { function updateHeaderStyles() {
let {top, height} = headerRef.current.getBoundingClientRect() const headerBoundingRect = headerRef.current?.getBoundingClientRect()
let top = headerBoundingRect?.top!
let height = headerBoundingRect?.height!
let scrollY = clamp( let scrollY = clamp(
window.scrollY, window.scrollY,
0, 0,
@ -336,7 +348,7 @@ export function Header() {
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})` let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
setProperty('--avatar-border-transform', borderTransform) setProperty('--avatar-border-transform', borderTransform)
setProperty('--avatar-border-opacity', scale === toScale ? 1 : 0) setProperty('--avatar-border-opacity', (scale === toScale ? 1 : 0).toString())
} }
function updateStyles() { function updateStyles() {
@ -346,11 +358,13 @@ export function Header() {
} }
updateStyles() updateStyles()
window.addEventListener('scroll', updateStyles, {passive: true})
const opts: AddEventListenerOptions & EventListenerOptions = {passive: true}
window.addEventListener('scroll', updateStyles, opts)
window.addEventListener('resize', updateStyles) window.addEventListener('resize', updateStyles)
return () => { return () => {
window.removeEventListener('scroll', updateStyles, {passive: true}) window.removeEventListener('scroll', updateStyles, opts)
window.removeEventListener('resize', updateStyles) window.removeEventListener('resize', updateStyles)
} }
}, [isHomePage]) }, [isHomePage])
@ -374,10 +388,8 @@ export function Header() {
className="top-0 order-last -mb-3 pt-3" className="top-0 order-last -mb-3 pt-3"
style={{position: 'var(--header-position)'}} style={{position: 'var(--header-position)'}}
> >
<div <div className="top-[var(--avatar-top,theme(spacing.3))] w-full"
className="top-[var(--avatar-top,theme(spacing.3))] w-full" style={headerInnerPosition}>
style={{position: 'var(--header-inner-position)'}}
>
<div className="relative"> <div className="relative">
<AvatarContainer <AvatarContainer
className="absolute left-0 top-3 origin-left transition-opacity" className="absolute left-0 top-3 origin-left transition-opacity"
@ -399,7 +411,7 @@ export function Header() {
<div <div
ref={headerRef} ref={headerRef}
className="top-0 z-10 h-16 pt-6" className="top-0 z-10 h-16 pt-6"
style={{position: 'var(--header-position)'}} style={headerPosition}
> >
<Container <Container
className="top-[var(--header-top,theme(spacing.6))] w-full" className="top-[var(--header-top,theme(spacing.6))] w-full"

View File

@ -1,7 +0,0 @@
import clsx from 'clsx'
export function Prose({ children, className }) {
return (
<div className={clsx(className, 'prose dark:prose-invert')}>{children}</div>
)
}

8
src/components/Prose.tsx Normal file
View File

@ -0,0 +1,8 @@
import {ReactNode} from 'react'
import clsx from 'clsx'
export function Prose({children, className}: { children: ReactNode, className: string }) {
return (
<div className={clsx(className, 'prose dark:prose-invert')}>{children}</div>
)
}

View File

@ -1,7 +1,19 @@
import {Container} from '@/components/Container' import {ReactNode} from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import {Container} from './Container'
export function SimpleLayout({title, intro, children, gradient}) { export function SimpleLayout({
title,
intro,
children,
gradient
}:
{
title: string,
intro: string,
children: ReactNode,
gradient: string
}) {
return ( return (
<Container className="mt-16 sm:mt-32"> <Container className="mt-16 sm:mt-32">
<header className="max-w-2xl"> <header className="max-w-2xl">

View File

@ -1,36 +0,0 @@
export function TwitterIcon(props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path d="M20.055 7.983c.011.174.011.347.011.523 0 5.338-3.92 11.494-11.09 11.494v-.003A10.755 10.755 0 0 1 3 18.186c.308.038.618.057.928.058a7.655 7.655 0 0 0 4.841-1.733c-1.668-.032-3.13-1.16-3.642-2.805a3.753 3.753 0 0 0 1.76-.07C5.07 13.256 3.76 11.6 3.76 9.676v-.05a3.77 3.77 0 0 0 1.77.505C3.816 8.945 3.288 6.583 4.322 4.737c1.98 2.524 4.9 4.058 8.034 4.22a4.137 4.137 0 0 1 1.128-3.86A3.807 3.807 0 0 1 19 5.274a7.657 7.657 0 0 0 2.475-.98c-.29.934-.9 1.729-1.713 2.233A7.54 7.54 0 0 0 22 5.89a8.084 8.084 0 0 1-1.945 2.093Z" />
</svg>
)
}
export function InstagramIcon(props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path d="M12 3c-2.444 0-2.75.01-3.71.054-.959.044-1.613.196-2.185.418A4.412 4.412 0 0 0 4.51 4.511c-.5.5-.809 1.002-1.039 1.594-.222.572-.374 1.226-.418 2.184C3.01 9.25 3 9.556 3 12s.01 2.75.054 3.71c.044.959.196 1.613.418 2.185.23.592.538 1.094 1.039 1.595.5.5 1.002.808 1.594 1.038.572.222 1.226.374 2.184.418C9.25 20.99 9.556 21 12 21s2.75-.01 3.71-.054c.959-.044 1.613-.196 2.185-.419a4.412 4.412 0 0 0 1.595-1.038c.5-.5.808-1.002 1.038-1.594.222-.572.374-1.226.418-2.184.044-.96.054-1.267.054-3.711s-.01-2.75-.054-3.71c-.044-.959-.196-1.613-.419-2.185A4.412 4.412 0 0 0 19.49 4.51c-.5-.5-1.002-.809-1.594-1.039-.572-.222-1.226-.374-2.184-.418C14.75 3.01 14.444 3 12 3Zm0 1.622c2.403 0 2.688.009 3.637.052.877.04 1.354.187 1.67.31.421.163.72.358 1.036.673.315.315.51.615.673 1.035.123.317.27.794.31 1.671.043.95.052 1.234.052 3.637s-.009 2.688-.052 3.637c-.04.877-.187 1.354-.31 1.67-.163.421-.358.72-.673 1.036a2.79 2.79 0 0 1-1.035.673c-.317.123-.794.27-1.671.31-.95.043-1.234.052-3.637.052s-2.688-.009-3.637-.052c-.877-.04-1.354-.187-1.67-.31a2.789 2.789 0 0 1-1.036-.673 2.79 2.79 0 0 1-.673-1.035c-.123-.317-.27-.794-.31-1.671-.043-.95-.052-1.234-.052-3.637s.009-2.688.052-3.637c.04-.877.187-1.354.31-1.67.163-.421.358-.72.673-1.036.315-.315.615-.51 1.035-.673.317-.123.794-.27 1.671-.31.95-.043 1.234-.052 3.637-.052Z" />
<path d="M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm0-7.622a4.622 4.622 0 1 0 0 9.244 4.622 4.622 0 0 0 0-9.244Zm5.884-.182a1.08 1.08 0 1 1-2.16 0 1.08 1.08 0 0 1 2.16 0Z" />
</svg>
)
}
export function GitHubIcon(props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.475 2 2 6.588 2 12.253c0 4.537 2.862 8.369 6.838 9.727.5.09.687-.218.687-.487 0-.243-.013-1.05-.013-1.91C7 20.059 6.35 18.957 6.15 18.38c-.113-.295-.6-1.205-1.025-1.448-.35-.192-.85-.667-.013-.68.788-.012 1.35.744 1.538 1.051.9 1.551 2.338 1.116 2.912.846.088-.666.35-1.115.638-1.371-2.225-.256-4.55-1.14-4.55-5.062 0-1.115.387-2.038 1.025-2.756-.1-.256-.45-1.307.1-2.717 0 0 .837-.269 2.75 1.051.8-.23 1.65-.346 2.5-.346.85 0 1.7.115 2.5.346 1.912-1.333 2.75-1.05 2.75-1.05.55 1.409.2 2.46.1 2.716.637.718 1.025 1.628 1.025 2.756 0 3.934-2.337 4.806-4.562 5.062.362.32.675.936.675 1.897 0 1.371-.013 2.473-.013 2.82 0 .268.188.589.688.486a10.039 10.039 0 0 0 4.932-3.74A10.447 10.447 0 0 0 22 12.253C22 6.588 17.525 2 12 2Z"
/>
</svg>
)
}
export function LinkedInIcon(props) {
return (
<svg viewBox="0 0 24 24" {...props}>
<path d="M18.335 18.339H15.67v-4.177c0-.996-.02-2.278-1.39-2.278-1.389 0-1.601 1.084-1.601 2.205v4.25h-2.666V9.75h2.56v1.17h.035c.358-.674 1.228-1.387 2.528-1.387 2.7 0 3.2 1.778 3.2 4.091v4.715zM7.003 8.575a1.546 1.546 0 01-1.548-1.549 1.548 1.548 0 111.547 1.549zm1.336 9.764H5.666V9.75H8.34v8.589zM19.67 3H4.329C3.593 3 3 3.58 3 4.297v15.406C3 20.42 3.594 21 4.328 21h15.338C20.4 21 21 20.42 21 19.703V4.297C21 3.58 20.4 3 19.666 3h.003z" />
</svg>
)
}

View File

@ -0,0 +1,42 @@
import {Props} from 'types'
export function TwitterIcon(props: Props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
d="M20.055 7.983c.011.174.011.347.011.523 0 5.338-3.92 11.494-11.09 11.494v-.003A10.755 10.755 0 0 1 3 18.186c.308.038.618.057.928.058a7.655 7.655 0 0 0 4.841-1.733c-1.668-.032-3.13-1.16-3.642-2.805a3.753 3.753 0 0 0 1.76-.07C5.07 13.256 3.76 11.6 3.76 9.676v-.05a3.77 3.77 0 0 0 1.77.505C3.816 8.945 3.288 6.583 4.322 4.737c1.98 2.524 4.9 4.058 8.034 4.22a4.137 4.137 0 0 1 1.128-3.86A3.807 3.807 0 0 1 19 5.274a7.657 7.657 0 0 0 2.475-.98c-.29.934-.9 1.729-1.713 2.233A7.54 7.54 0 0 0 22 5.89a8.084 8.084 0 0 1-1.945 2.093Z"/>
</svg>
)
}
export function InstagramIcon(props: Props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
d="M12 3c-2.444 0-2.75.01-3.71.054-.959.044-1.613.196-2.185.418A4.412 4.412 0 0 0 4.51 4.511c-.5.5-.809 1.002-1.039 1.594-.222.572-.374 1.226-.418 2.184C3.01 9.25 3 9.556 3 12s.01 2.75.054 3.71c.044.959.196 1.613.418 2.185.23.592.538 1.094 1.039 1.595.5.5 1.002.808 1.594 1.038.572.222 1.226.374 2.184.418C9.25 20.99 9.556 21 12 21s2.75-.01 3.71-.054c.959-.044 1.613-.196 2.185-.419a4.412 4.412 0 0 0 1.595-1.038c.5-.5.808-1.002 1.038-1.594.222-.572.374-1.226.418-2.184.044-.96.054-1.267.054-3.711s-.01-2.75-.054-3.71c-.044-.959-.196-1.613-.419-2.185A4.412 4.412 0 0 0 19.49 4.51c-.5-.5-1.002-.809-1.594-1.039-.572-.222-1.226-.374-2.184-.418C14.75 3.01 14.444 3 12 3Zm0 1.622c2.403 0 2.688.009 3.637.052.877.04 1.354.187 1.67.31.421.163.72.358 1.036.673.315.315.51.615.673 1.035.123.317.27.794.31 1.671.043.95.052 1.234.052 3.637s-.009 2.688-.052 3.637c-.04.877-.187 1.354-.31 1.67-.163.421-.358.72-.673 1.036a2.79 2.79 0 0 1-1.035.673c-.317.123-.794.27-1.671.31-.95.043-1.234.052-3.637.052s-2.688-.009-3.637-.052c-.877-.04-1.354-.187-1.67-.31a2.789 2.789 0 0 1-1.036-.673 2.79 2.79 0 0 1-.673-1.035c-.123-.317-.27-.794-.31-1.671-.043-.95-.052-1.234-.052-3.637s.009-2.688.052-3.637c.04-.877.187-1.354.31-1.67.163-.421.358-.72.673-1.036.315-.315.615-.51 1.035-.673.317-.123.794-.27 1.671-.31.95-.043 1.234-.052 3.637-.052Z"/>
<path
d="M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm0-7.622a4.622 4.622 0 1 0 0 9.244 4.622 4.622 0 0 0 0-9.244Zm5.884-.182a1.08 1.08 0 1 1-2.16 0 1.08 1.08 0 0 1 2.16 0Z"/>
</svg>
)
}
export function GitHubIcon(props: Props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.475 2 2 6.588 2 12.253c0 4.537 2.862 8.369 6.838 9.727.5.09.687-.218.687-.487 0-.243-.013-1.05-.013-1.91C7 20.059 6.35 18.957 6.15 18.38c-.113-.295-.6-1.205-1.025-1.448-.35-.192-.85-.667-.013-.68.788-.012 1.35.744 1.538 1.051.9 1.551 2.338 1.116 2.912.846.088-.666.35-1.115.638-1.371-2.225-.256-4.55-1.14-4.55-5.062 0-1.115.387-2.038 1.025-2.756-.1-.256-.45-1.307.1-2.717 0 0 .837-.269 2.75 1.051.8-.23 1.65-.346 2.5-.346.85 0 1.7.115 2.5.346 1.912-1.333 2.75-1.05 2.75-1.05.55 1.409.2 2.46.1 2.716.637.718 1.025 1.628 1.025 2.756 0 3.934-2.337 4.806-4.562 5.062.362.32.675.936.675 1.897 0 1.371-.013 2.473-.013 2.82 0 .268.188.589.688.486a10.039 10.039 0 0 0 4.932-3.74A10.447 10.447 0 0 0 22 12.253C22 6.588 17.525 2 12 2Z"
/>
</svg>
)
}
export function LinkedInIcon(props: Props) {
return (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M18.335 18.339H15.67v-4.177c0-.996-.02-2.278-1.39-2.278-1.389 0-1.601 1.084-1.601 2.205v4.25h-2.666V9.75h2.56v1.17h.035c.358-.674 1.228-1.387 2.528-1.387 2.7 0 3.2 1.778 3.2 4.091v4.715zM7.003 8.575a1.546 1.546 0 01-1.548-1.549 1.548 1.548 0 111.547 1.549zm1.336 9.764H5.666V9.75H8.34v8.589zM19.67 3H4.329C3.593 3 3 3.58 3 4.297v15.406C3 20.42 3.594 21 4.328 21h15.338C20.4 21 21 20.42 21 19.703V4.297C21 3.58 20.4 3 19.666 3h.003z"/>
</svg>
)
}

View File

@ -1,11 +1,44 @@
import useSWR from 'swr' import useSWR from 'swr'
import fetcher from '@/lib/fetcher' import fetcher from '@/lib/fetcher'
import Image from 'next/future/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
import {useEffect} from 'react' import {useEffect, ReactElement, ElementType} from 'react'
import {animate} from 'motion' import {animate} from 'motion'
type PlayerStateParams = {
path: string
options?: {
refreshInterval: number
}
}
type Status = {
as?: ElementType
isPlaying: boolean
}
type Artist = {
as?: ElementType
artist: string
}
type Title = {
as?: ElementType
title: string
songUrl: string
className?: string
}
type Album = {
album: string
albumImageUrl: string
}
type Song = {
as?: ElementType
} & Artist & Title & Album & Status
function AnimatedBars() { function AnimatedBars() {
useEffect(() => { useEffect(() => {
animate( animate(
@ -75,7 +108,7 @@ function AnimatedBars() {
) )
} }
function usePlayerState(path, options) { function usePlayerState({path, options}: PlayerStateParams) {
const {data, error, isLoading} = useSWR(`/api/spotify/${path}`, fetcher, options) const {data, error, isLoading} = useSWR(`/api/spotify/${path}`, fetcher, options)
return { return {
@ -85,7 +118,7 @@ function usePlayerState(path, options) {
} }
} }
function Song({as: Component = 'div', artist, title, songUrl, album, albumImageUrl, isPlaying}) { function Song({as: Component = 'div', artist, title, songUrl, album, albumImageUrl, isPlaying}: Song) {
return ( return (
<Component <Component
className="flex items-center space-x-4"> className="flex items-center space-x-4">
@ -110,7 +143,7 @@ function Song({as: Component = 'div', artist, title, songUrl, album, albumImageU
) )
} }
Song.Album = function SongAlbum({album, albumImageUrl}) { Song.Album = function SongAlbum({album, albumImageUrl}: Album) {
return ( return (
<Image <Image
width="64" width="64"
@ -122,7 +155,7 @@ Song.Album = function SongAlbum({album, albumImageUrl}) {
) )
} }
Song.Title = function SongTitle({as: Component = 'h2', title, songUrl, className}) { Song.Title = function SongTitle({as: Component = 'h2', title, songUrl, className}: Title) {
return ( return (
<Component className={clsx(className, 'text-sm font-semibold text-zinc-800')}> <Component className={clsx(className, 'text-sm font-semibold text-zinc-800')}>
<Link href={songUrl}> <Link href={songUrl}>
@ -132,7 +165,7 @@ Song.Title = function SongTitle({as: Component = 'h2', title, songUrl, className
) )
} }
Song.Artist = function SongArtist({as: Component = 'p', artist}) { Song.Artist = function SongArtist({as: Component = 'p', artist}: Artist) {
return ( return (
<Component className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none"> <Component className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none">
{artist} {artist}
@ -140,9 +173,9 @@ Song.Artist = function SongArtist({as: Component = 'p', artist}) {
) )
} }
Song.Skeleton = function SongSkeleton({as: Component = 'div'}) { Song.Skeleton = function SongSkeleton() {
return ( return (
<Component className="flex items-center space-x-4 animate-pulse"> <div className="flex items-center space-x-4 animate-pulse">
<div <div
className="w-[64px] h-[64px] bg-zinc-100 rounded-2xl dark:bg-zinc-900" className="w-[64px] h-[64px] bg-zinc-100 rounded-2xl dark:bg-zinc-900"
/> />
@ -150,14 +183,18 @@ Song.Skeleton = function SongSkeleton({as: Component = 'div'}) {
<p className="w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/> <p className="w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
<p className="mt-3 w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/> <p className="mt-3 w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
</div> </div>
</Component> </div>
) )
} }
function LastPlayed() { function LastPlayed(): ReactElement | null {
const {song, isLoading, isError} = usePlayerState('last-played') const {song, isLoading, isError} = usePlayerState(
{
path: 'last-played'
}
)
if (isError) return if (isError) return null
return ( return (
<> <>
@ -170,13 +207,19 @@ function LastPlayed() {
) )
} }
export function SpotifyPlayer() { export function SpotifyPlayer(): ReactElement | null {
const {song, isLoading, isError} = usePlayerState('currently-playing', {refreshInterval: 30000}) const {song, isLoading, isError} = usePlayerState(
{
path: 'currently-playing',
options: {
refreshInterval: 30000
}
})
if (isError) return if (isError) return null
return ( return (
<div className="grid place-items-start"> <div className="grid">
{isLoading {isLoading
? <Song.Skeleton/> ? <Song.Skeleton/>
: song?.isPlaying : song?.isPlaying

View File

@ -28,7 +28,8 @@ export async function generateRssFeed() {
}) })
for (let article of articles) { for (let article of articles) {
let url = `${siteUrl}/writing/${article.slug}` let url = `${siteUrl}/writing/${article.slug}`;
let html = ReactDOMServer.renderToStaticMarkup( let html = ReactDOMServer.renderToStaticMarkup(
<article.component isRssFeed/> <article.component isRssFeed/>
) )

View File

@ -0,0 +1,67 @@
import glob from 'fast-glob'
import path from 'path'
import {getAllArticles} from '@/lib/getAllArticles'
import {writeFile} from 'fs/promises'
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL
async function createSitemap(pages) {
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${pages.map((url) => {
return `
<url>
<loc>${url}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>`
}).join("")}
</urlset>`
await writeFile('./public/sitemap.xml', sitemap, 'utf8')
}
async function createRobots() {
const robots = `# *
User-agent: *
Allow: /
# Host
Host: ${BASE_URL}
# Sitemaps
Sitemap: ${BASE_URL}/sitemap.xml`
await writeFile('./public/robots.txt', robots, 'utf8')
}
export async function generateSitemap() {
const excluded = [
'_app.tsx',
'_document.tsx',
'index.tsx'
]
const pages = (await glob(['*.tsx', '*.jsx'], {
cwd: path.join(process.cwd(), 'src/pages/'),
})).filter((page) => {
return !excluded
.includes(page)
}).map((page) => {
return `${BASE_URL}/${page}`
.replace(/\.(tsx|jsx)$/, '')
})
pages.unshift(`${BASE_URL}/`)
pages.push(`${BASE_URL}/writing`)
const articles = await getAllArticles()
const slugs = articles.map(({slug}) => `${BASE_URL}/writing/${slug}`)
const allPages = [...pages, ...slugs]
await Promise.all([
await createSitemap(allPages),
await createRobots()
])
}

View File

@ -1,38 +0,0 @@
import { useEffect, useRef } from 'react'
import { Footer } from '@/components/Footer'
import { Header } from '@/components/Header'
import '@/styles/tailwind.css'
import 'focus-visible'
function usePrevious(value) {
let ref = useRef()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
export default function App({ Component, pageProps, router }) {
let previousPathname = usePrevious(router.pathname)
return (
<>
<div className="fixed inset-0 flex justify-center sm:px-8">
<div className="flex w-full max-w-7xl lg:px-8">
<div className="w-full bg-white dark:bg-black dark:ring-zinc-300/20" />
</div>
</div>
<div className="relative">
<Header />
<main>
<Component previousPathname={previousPathname} {...pageProps} />
</main>
<Footer />
</div>
</>
)
}

38
src/pages/_app.tsx Normal file
View File

@ -0,0 +1,38 @@
import {useEffect, useRef} from 'react'
import type {AppProps} from 'next/app'
import {Header} from '@/components/Header'
import {Footer} from '@/components/Footer'
import '../../styles/tailwind.css'
import 'focus-visible'
function usePrevious(value: string): string | null {
let ref = useRef<string | null>(null)
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
export default function App({Component, pageProps, router}: AppProps) {
let previousPathname = usePrevious(router.pathname)
return (
<>
<div className="fixed inset-0 flex justify-center sm:px-8">
<div className="flex w-full max-w-7xl lg:px-8">
<div className="w-full bg-white dark:bg-black dark:ring-zinc-300/20"/>
</div>
</div>
<div className="relative">
<Header/>
<main>
<Component previousPathname={previousPathname} {...pageProps} />
</main>
<Footer/>
</div>
</>
)
}

View File

@ -1,7 +1,7 @@
import {Head, Html, Main, NextScript} from 'next/document' import {Html, Head, Main, NextScript} from 'next/document'
const modeScript = ` const modeScript = `
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
updateMode() updateMode()
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions) darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
@ -67,10 +67,11 @@ export default function Document() {
href="/static/icons/apple-touch-icon.png" href="/static/icons/apple-touch-icon.png"
/> />
</Head> </Head>
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
<body className="flex h-full flex-col dark:bg-black"> <body className="flex h-full flex-col dark:bg-black">
<Main/> <Main/>
<NextScript/> <NextScript/>
</body> </body>
</Html> </Html>
) );
} }

View File

@ -1,4 +1,5 @@
import Image from 'next/future/image' import Image from 'next/image'
import {ElementType, ReactNode} from 'react'
import Head from 'next/head' import Head from 'next/head'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
@ -9,10 +10,22 @@ import {
LinkedInIcon, LinkedInIcon,
TwitterIcon TwitterIcon
} from '@/components/SocialIcons' } from '@/components/SocialIcons'
import {Props} from 'types'
import photoOfMeLg from '@/images/photo-of-me-lg.jpg' import photoOfMeLg from '@/images/photo-of-me-lg.jpg'
import awsCCPBadge from '@/images/aws-certified-cloud-practitioner-badge.png' import awsCCPBadge from '@/images/aws-certified-cloud-practitioner-badge.png'
function SocialLink({className, href, children, icon: Icon}) { function SocialLink({
className,
href,
children,
icon: Icon
}:
{
className: string,
href: string,
children: ReactNode,
icon: ElementType
}) {
return ( return (
<li className={clsx(className, 'flex')}> <li className={clsx(className, 'flex')}>
<Link <Link
@ -26,7 +39,7 @@ function SocialLink({className, href, children, icon: Icon}) {
) )
} }
function MailIcon(props) { function MailIcon(props: Props) {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}> <svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path <path

View File

@ -1,19 +1,41 @@
import Head from 'next/head' import Head from 'next/head'
import Link from 'next/link' import Link from 'next/link'
import {GetStaticProps} from 'next'
import {ElementType} from 'react'
import {Button} from '@/components/Button'
import {Card} from '@/components/Card' import {Card} from '@/components/Card'
import {Button} from '@/components/Button'
import {Container} from '@/components/Container' import {Container} from '@/components/Container'
import { import {
GitHubIcon, GitHubIcon,
LinkedInIcon, LinkedInIcon,
TwitterIcon TwitterIcon
} from '@/components/SocialIcons' } from '@/components/SocialIcons'
import {generateRssFeed} from '@/lib/generateRssFeed'
import {getAllArticles} from '@/lib/getAllArticles'
import {formatDate} from '@/lib/formatDate' import {formatDate} from '@/lib/formatDate'
import {generateRssFeed} from '@/lib/generateRssFeed'
import {generateSitemap} from '@/lib/generateSitemap'
import {getAllArticles} from '@/lib/getAllArticles'
import {Article} from 'types'
function BriefcaseIcon(props) { type Work = {
company: string
title: string
start: {
label: string
dateTime: string
}
end: {
label: string
dateTime: string
}
}
type SocialLink = {
href: string
icon: ElementType
}
function BriefcaseIcon(props: { className: string }) {
return ( return (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -36,7 +58,7 @@ function BriefcaseIcon(props) {
) )
} }
function ArrowDownIcon(props) { function ArrowDownIcon(props: { className: string }) {
return ( return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path <path
@ -49,7 +71,7 @@ function ArrowDownIcon(props) {
) )
} }
function Article({article}) { function Article(article: Article) {
return ( return (
<Card as="article"> <Card as="article">
<Card.Title href={`/writing/${article.slug}`}> <Card.Title href={`/writing/${article.slug}`}>
@ -64,9 +86,9 @@ function Article({article}) {
) )
} }
function SocialLink({icon: Icon, ...props}) { function SocialLink({icon: Icon, href}: SocialLink) {
return ( return (
<Link className="group -m-1 p-1" {...props}> <Link className="group -m-1 p-1" href={href}>
<Icon <Icon
className="h-6 w-6 fill-zinc-500 transition group-hover:fill-zinc-600 dark:fill-zinc-400 dark:group-hover:fill-zinc-300"/> className="h-6 w-6 fill-zinc-500 transition group-hover:fill-zinc-600 dark:fill-zinc-400 dark:group-hover:fill-zinc-300"/>
</Link> </Link>
@ -74,21 +96,30 @@ function SocialLink({icon: Icon, ...props}) {
} }
function Resume() { function Resume() {
let work = [ const work: Work[] = [
{ {
company: 'Aer Lingus', company: 'Aer Lingus',
title: 'Software engineer', title: 'Software engineer',
start: '2022', start: {
label: '2022',
dateTime: '2022'
},
end: { end: {
label: 'present', label: 'present',
dateTime: new Date().getFullYear(), dateTime: new Date().getFullYear().toString(),
}, }
}, },
{ {
company: 'Apple', company: 'Apple',
title: 'At home advisor', title: 'At home advisor',
start: '2014', start: {
end: '2018', label: '2014',
dateTime: '2014'
},
end: {
label: '2018',
dateTime: '2018'
},
} }
] ]
@ -139,7 +170,7 @@ function Resume() {
) )
} }
export default function Home({articles}) { export default function Home({articles}: { articles: Article[] }) {
return ( return (
<> <>
<Head> <Head>
@ -224,8 +255,14 @@ export default function Home({articles}) {
<Container className="mt-24 md:mt-28"> <Container className="mt-24 md:mt-28">
<div className="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none lg:grid-cols-2"> <div className="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none lg:grid-cols-2">
<div className="flex flex-col gap-16"> <div className="flex flex-col gap-16">
{articles.map((article) => ( {articles.map(({slug, title, description, date}) => (
<Article key={article.slug} article={article}/> <Article
key={slug}
title={title}
description={description}
slug={slug}
date={date}
/>
))} ))}
</div> </div>
<div className="space-y-10 lg:pl-16 xl:pl-24"> <div className="space-y-10 lg:pl-16 xl:pl-24">
@ -237,9 +274,10 @@ export default function Home({articles}) {
) )
} }
export async function getStaticProps() { export const getStaticProps: GetStaticProps = async () => {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
await generateRssFeed() await generateRssFeed()
await generateSitemap()
} }
return { return {
@ -247,6 +285,6 @@ export async function getStaticProps() {
articles: (await getAllArticles()) articles: (await getAllArticles())
.slice(0, 1) .slice(0, 1)
.map(({component, ...meta}) => meta), .map(({component, ...meta}) => meta),
}, }
} }
} }

View File

@ -2,8 +2,18 @@ import Head from 'next/head'
import {Card} from '@/components/Card' import {Card} from '@/components/Card'
import {SimpleLayout} from '@/components/SimpleLayout' import {SimpleLayout} from '@/components/SimpleLayout'
import {Props} from 'types'
const projects = [ type Project = {
name: string
description: string
link: {
href: string
label: string
}
}
const projects: Project[] = [
{ {
name: 'Portfolio', name: 'Portfolio',
description: description:
@ -36,7 +46,7 @@ const projects = [
} }
] ]
function LinkIcon(props) { function LinkIcon(props: Props) {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}> <svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path <path

View File

@ -3,8 +3,6 @@ import Head from 'next/head'
import {Card} from '@/components/Card' import {Card} from '@/components/Card'
import {Section} from '@/components/Section' import {Section} from '@/components/Section'
import {SimpleLayout} from '@/components/SimpleLayout' import {SimpleLayout} from '@/components/SimpleLayout'
import Link from "next/link";
function ToolsSection({children, ...props}) { function ToolsSection({children, ...props}) {
return ( return (

View File

@ -1,6 +1,4 @@
import {ArticleLayout} from '@/components/ArticleLayout' import {ArticleLayout} from '@/components/ArticleLayout'
import Image from 'next/future/image'
import forestForkingPath from './jens-lelie-u0vgcIOQG08-unsplash.jpg'
export const meta = { export const meta = {
author: 'Ryan Freeman', author: 'Ryan Freeman',
@ -8,16 +6,13 @@ export const meta = {
title: 'A personal journey in software engineering', title: 'A personal journey in software engineering',
description: description:
'Hello there! If you\'re reading this, you\'ve likely stumbled upon my website — welcome! My name is Ryan Freeman, and I\'m a full-stack developer with a passion for creating intuitive and dynamic web applications.', 'Hello there! If you\'re reading this, you\'ve likely stumbled upon my website — welcome! My name is Ryan Freeman, and I\'m a full-stack developer with a passion for creating intuitive and dynamic web applications.',
ogImage: `/static/images/jens-lelie-u0vgcIOQG08-unsplash.jpg`
} }
export default (props) => <ArticleLayout meta={meta} {...props} /> export default (props) => <ArticleLayout
author={meta.author}
<Image date={meta.date}
src={forestForkingPath} title={meta.title}
alt="Photo by Jens Lelie on Unsplash" description={meta.description} {...props} />
placeholder="blur"
/>
Hello there! Hello there!

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

View File

@ -7,7 +7,11 @@ export const meta = {
description: 'Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a tsconfig.json file in the project root with touch tsconfig.json.' description: 'Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a tsconfig.json file in the project root with touch tsconfig.json.'
} }
export default (props) => <ArticleLayout meta={meta} {...props} /> export default (props) => <ArticleLayout
author={meta.author}
date={meta.date}
title={meta.title}
description={meta.description} {...props} />
# Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`. # Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`.
Next, run `next dev` and the _tsconfig.json_ file will be populated with the default values, you may customise this configuration to your liking. A file called _next-env.d.ts_ will be created at the project root, this file **should not** be removed or edited. Next, run `next dev` and the _tsconfig.json_ file will be populated with the default values, you may customise this configuration to your liking. A file called _next-env.d.ts_ will be created at the project root, this file **should not** be removed or edited.

View File

@ -1,3 +1,4 @@
import {GetStaticProps} from 'next'
import Head from 'next/head' import Head from 'next/head'
import {Card} from '@/components/Card' import {Card} from '@/components/Card'
@ -5,7 +6,9 @@ import {SimpleLayout} from '@/components/SimpleLayout'
import {getAllArticles} from '@/lib/getAllArticles' import {getAllArticles} from '@/lib/getAllArticles'
import {formatDate} from '@/lib/formatDate' import {formatDate} from '@/lib/formatDate'
function Article({article}) { import {Article} from 'types'
function Article({article}: { article: Article }) {
return ( return (
<article> <article>
<Card small={true}> <Card small={true}>
@ -25,7 +28,7 @@ function Article({article}) {
) )
} }
export default function ArticlesIndex({articles}) { export default function ArticlesIndex({articles}: { articles: Article[] }) {
return ( return (
<> <>
<Head> <Head>
@ -60,10 +63,10 @@ export default function ArticlesIndex({articles}) {
) )
} }
export async function getStaticProps() { export const getStaticProps: GetStaticProps = async () => {
return { return {
props: { props: {
articles: (await getAllArticles()).map(({component, ...meta}) => meta), articles: (await getAllArticles()).map(({component, ...meta}) => meta),
}, }
} }
} }

View File

@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ['./src/**/*.{js,jsx}'], content: ['./src/**/*.{js,jsx,tsx}'],
darkMode: 'class', darkMode: 'class',
plugins: [ plugins: [
require('@tailwindcss/typography'), require('@tailwindcss/typography'),

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

13
types/index.ts Normal file
View File

@ -0,0 +1,13 @@
import {ReactNode} from 'react'
export type Article = {
slug: string
title: string
date: string
description: string
}
export type Props = {
className?: string
children?: ReactNode
}