Updated portfolio

This commit is contained in:
Ryan 2022-12-06 12:54:34 +00:00 committed by r-freeman
parent 60e997112f
commit 8e09e0cf1f
67 changed files with 13773 additions and 5831 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=
NEXT_PUBLIC_SITE_URL=https://example.com

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

13
.gitignore vendored
View File

@ -1,8 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea/
out/
# dependencies
/node_modules
/.pnp
@ -26,12 +23,14 @@ out/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
# vercel
.vercel
# generated files
/public/rss/
.idea/

View File

@ -0,0 +1,11 @@
# Welcome to my portfolio website!
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.
In addition to functioning as a personal blog, this website shows what I'm listening to on Spotify. This
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
together.

View File

@ -1,9 +0,0 @@
export default function ExternalLink() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" width="24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
)
};

View File

@ -1,11 +0,0 @@
export default function GitHub() {
return (
<svg aria-hidden="true" height="24" viewBox="0 0 16 16" version="1.1" width="24"
data-view-component="true">
<path
fill="currentColor"
fillRule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
)
};

View File

@ -1,22 +0,0 @@
import styles from '../styles/Home.module.scss';
import ExternalLink from './ExternalLink';
export default function Projects({projectData}) {
return (
<section>
<h2 className={styles.description}>
Personal Projects
</h2>
<div className={styles.grid}>
{projectData.map((project, idx) => (
<a href={project.href}
className={styles.card}
key={idx}>
<h3>{project.name}<ExternalLink/></h3>
<p>{project.description}</p>
</a>
))}
</div>
</section>
)
};

View File

@ -1,27 +0,0 @@
[
{
"name": "Intellagent",
"description": "Full stack help desk application powered by AI and machine learning.",
"href": "https://github.com/r-freeman/intellagent-server"
},
{
"name": "Resume",
"description": "A fully customisable online resume created with Next.js.",
"href": "https://github.com/r-freeman/resume"
},
{
"name": "Super Mario Run",
"description": "Mobile game inspired by Super Mario Bros, built with Phaser.",
"href": "https://r-freeman.github.io/super-mario-run/"
},
{
"name": "ASOS Prototype",
"description": "Working prototype of ASOS' website built in Axure RP 8.",
"href": "https://github.com/r-freeman/ASOS-Axure-Prototype"
},
{
"name": "College App",
"description": "Full stack application built with Laravel 6, Vue.js and Tailwind CSS.",
"href": "https://github.com/r-freeman/college-app"
}
]

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@ -1,7 +0,0 @@
import fs from 'fs';
export function getCustomData(file) {
const path = `${process.env.dataDirectory}/${file}`;
return JSON.parse(fs.readFileSync(path));
}

View File

@ -1,7 +0,0 @@
const path = require("path");
const dataDirectory = path.join(process.cwd(), "data");
module.exports = {
env: {dataDirectory},
reactStrictMode: true,
}

35
next.config.mjs Normal file
View File

@ -0,0 +1,35 @@
import nextMDX from '@next/mdx'
import remarkGfm from 'remark-gfm'
import rehypePrism from '@mapbox/rehype-prism'
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['jsx', 'js', 'mdx'],
reactStrictMode: true,
swcMinify: true,
experimental: {
newNextLinkBehavior: true,
scrollRestoration: true,
},
images: {
domains: ['i.scdn.co']
},
async rewrites() {
return [
{
source: '/api/:path',
destination: 'https://ryanfreeman.dev/:path/',
}
]
}
}
const withMDX = nextMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrism],
},
})
export default withMDX(nextConfig)

16671
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
{
"name": "portfolio",
"name": "tailwindui-template",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@ -7,15 +8,33 @@
"start": "next start",
"lint": "next lint"
},
"browserslist": "defaults, not ie <= 11",
"dependencies": {
"@fontsource/inter": "^4.5.1",
"next": "12.0.4",
"react": "17.0.2",
"react-dom": "17.0.2",
"sass": "^1.43.4"
"@headlessui/react": "^1.7.0",
"@mapbox/rehype-prism": "^0.8.0",
"@mdx-js/loader": "^2.1.2",
"@next/mdx": "^12.2.3",
"@tailwindcss/typography": "^0.5.4",
"autoprefixer": "^10.4.7",
"clsx": "^1.2.0",
"fast-glob": "^3.2.11",
"feed": "^4.2.2",
"focus-visible": "^5.2.0",
"motion": "^10.15.3",
"next": "^12.3.0",
"node-fetch": "^3.3.0",
"postcss-focus-visible": "^6.0.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"remark-gfm": "^3.0.1",
"swr": "^2.0.0",
"tailwindcss": "^3.1.4"
},
"devDependencies": {
"eslint": "7.32.0",
"eslint-config-next": "12.0.4"
"@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

@ -1,9 +0,0 @@
import '@fontsource/inter/400.css';
import '@fontsource/inter/700.css';
import '../styles/globals.scss';
function App({Component, pageProps}) {
return <Component {...pageProps} />
}
export default App;

View File

@ -1,17 +0,0 @@
import Document, {Html, Head, Main, NextScript} from 'next/document';
class Doc extends Document {
render() {
return (
<Html lang="en">
<Head/>
<body>
<Main/>
<NextScript/>
</body>
</Html>
)
}
}
export default Doc;

View File

@ -1,51 +0,0 @@
import Head from 'next/head';
import styles from '../styles/Home.module.scss';
import GitHub from '../components/GitHub';
import {getCustomData} from '../lib/custom';
import Projects from '../components/Projects';
export default function Home({projectData}) {
return (
<div className={styles.container}>
<Head>
<title>Ryan Freeman | Full Stack Developer in Dublin, Ireland</title>
<meta name="author" content="Ryan Freeman"/>
<meta name="description" content="Full Stack Developer in Dublin, Ireland"/>
<meta name="keywords" content="React, JavaScript, Developer"/>
<meta property="og:type" content="website"/>
<meta property="og:url" content="https://ryanfreeman.dev"/>
<meta property="og:site_name" content="Ryan Freeman"/>
<meta property="og:title" content="Ryan Freeman | Full Stack Developer in Dublin, Ireland"/>
<meta property="og:description" content="Full Stack Developer in Dublin, Ireland"/>
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>👋</text></svg>"/>
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Ryan Freeman{' '}<span>Full Stack Developer</span>
</h1>
<p className={styles.bio}>
I'm currently working as a Software Engineer at Aer Lingus
</p>
<div className={styles.links}>
<a href="https://github.com/r-freeman">
<GitHub/>
<span className={styles.srOnly}>GitHub profile</span>
</a>
</div>
<Projects
projectData={projectData}/>
</main>
</div>
)
}
export async function getStaticProps() {
const projectData = getCustomData('projects.json');
return {
props: {
projectData
}
}
}

9
postcss.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
plugins: {
tailwindcss: {},
'postcss-focus-visible': {
replaceWith: '[data-focus-visible-added]',
},
autoprefixer: {},
},
}

5
prettier.config.js Normal file
View File

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

BIN
public/Ryan Freeman CV.pdf Normal file

Binary file not shown.

View File

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

View File

@ -1,14 +1,50 @@
<?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
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">
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
<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>
<url>
<loc>https://ryanfreeman.dev/</loc>
<lastmod>2021-10-18T19:53:53+00:00</lastmod>
</url>
</urlset>
</urlset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@ -0,0 +1,120 @@
import Head from 'next/head'
import {useRouter} from 'next/router'
import {Container} from '@/components/Container'
import {formatDate} from '@/lib/formatDate'
import {Prose} from '@/components/Prose'
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({
children,
meta,
isRssFeed = false,
previousPathname,
}) {
let router = useRouter()
if (isRssFeed) {
return children
}
return (
<>
<Head>
<title>{`${meta.title} - Ryan Freeman`}</title>
<meta name="description" content={meta.description}/>
<meta
property="og:url"
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
/>
<meta
property="og:type"
content="website"
/>
<meta
property="og:title"
content={meta.title}
/>
<meta
property="og:description"
content={meta.description}
/>
{meta.ogImage &&
<>
<meta
property="og:image"
content={meta.ogImage}
/>
<meta
name="twitter:card"
content="summary_large_image"
/>
<meta
name="twitter:image"
content={meta.ogImage}
/>
</>
}
<meta
property="twitter:domain"
content="ryanfreeman.dev"
/>
<meta
property="twitter:url"
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
/>
<meta
name="twitter:title"
content={meta.title}
/>
<meta
name="twitter:description"
content={meta.description}
/>
</Head>
<Container className="mt-16 lg:mt-32">
<div className="xl:relative">
<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>
<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">
{meta.title}
</h1>
<time
dateTime={meta.date}
className="order-first flex items-center text-base text-zinc-500 dark:text-zinc-400"
>
<span>{formatDate(meta.date)}</span>
</time>
</header>
<Prose className="mt-8">{children}</Prose>
</article>
</div>
</div>
</Container>
</>
)
}

23
src/components/Button.jsx Normal file
View File

@ -0,0 +1,23 @@
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} />
)
}

94
src/components/Card.jsx Normal file
View File

@ -0,0 +1,94 @@
import Link from 'next/link'
import clsx from 'clsx'
function ChevronRightIcon(props) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path
d="M6.75 5.75 9.25 8l-2.5 2.25"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export function Card({ as: Component = 'div', className, children }) {
return (
<Component
className={clsx(className, 'group relative flex flex-col items-start')}
>
{children}
</Component>
)
}
Card.Link = function CardLink({ children, ...props }) {
return (
<>
<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" />
<Link {...props}>
<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>
</Link>
</>
)
}
Card.Title = function CardTitle({ as: Component = 'h2', href, children }) {
return (
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
</Component>
)
}
Card.Description = function CardDescription({ children }) {
return (
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{children}
</p>
)
}
Card.Cta = function CardCta({ children }) {
return (
<div
aria-hidden="true"
className="relative z-10 mt-4 flex items-center text-sm font-medium text-zinc-400 group-hover:text-indigo-500 dark:text-zinc-200"
>
{children}
<ChevronRightIcon className="ml-1 h-4 w-4 stroke-current" />
</div>
)
}
Card.Eyebrow = function CardEyebrow({
as: Component = 'p',
decorate = false,
className,
children,
...props
}) {
return (
<Component
className={clsx(
className,
'relative z-10 order-first mb-3 flex items-center text-sm text-zinc-500 dark:text-zinc-400',
decorate && 'pl-3.5'
)}
{...props}
>
{decorate && (
<span
className="absolute inset-y-0 left-0 flex items-center"
aria-hidden="true"
>
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
</span>
)}
{children}
</Component>
)
}

View File

@ -0,0 +1,42 @@
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

37
src/components/Footer.jsx Normal file
View File

@ -0,0 +1,37 @@
import Link from 'next/link'
import {Container} from '@/components/Container'
import {SpotifyPlayer} from '@/components/SpotifyPlayer'
function NavLink({href, children}) {
return (
<Link
href={href}
className="transition hover:text-indigo-500 dark:hover:text-indigo-400"
>
{children}
</Link>
)
}
export function Footer() {
return (
<footer className="mt-32">
<Container.Outer>
<div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
<Container.Inner>
<SpotifyPlayer/>
<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">
<NavLink href="/about">About</NavLink>
<NavLink href="/writing">Writing</NavLink>
<NavLink href="/projects">Projects</NavLink>
<NavLink href="/uses">Uses</NavLink>
</div>
</div>
</Container.Inner>
</div>
</Container.Outer>
</footer>
)
}

432
src/components/Header.jsx Normal file
View File

@ -0,0 +1,432 @@
import Image from 'next/future/image'
import Link from 'next/link'
import {useRouter} from 'next/router'
import {Popover, Transition} from '@headlessui/react'
import clsx from 'clsx'
import {Container} from '@/components/Container'
import avatar from '@/images/avatar.jpg'
import {Fragment, useEffect, useRef} from 'react'
function CloseIcon(props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
d="m17.25 6.75-10.5 10.5M6.75 6.75l10.5 10.5"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function ChevronDownIcon(props) {
return (
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
<path
d="M1.75 1.75 4 4.25l2.25-2.5"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function SunIcon(props) {
return (
<svg
viewBox="0 0 24 24"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<path
d="M8 12.25A4.25 4.25 0 0 1 12.25 8v0a4.25 4.25 0 0 1 4.25 4.25v0a4.25 4.25 0 0 1-4.25 4.25v0A4.25 4.25 0 0 1 8 12.25v0Z"/>
<path
d="M12.25 3v1.5M21.5 12.25H20M18.791 18.791l-1.06-1.06M18.791 5.709l-1.06 1.06M12.25 20v1.5M4.5 12.25H3M6.77 6.77 5.709 5.709M6.77 17.73l-1.061 1.061"
fill="none"
/>
</svg>
)
}
function MoonIcon(props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
d="M17.25 16.22a6.937 6.937 0 0 1-9.47-9.47 7.451 7.451 0 1 0 9.47 9.47ZM12.75 7C17 7 17 2.75 17 2.75S17 7 21.25 7C17 7 17 11.25 17 11.25S17 7 12.75 7Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function MobileNavItem({href, children}) {
return (
<li>
<Popover.Button as={Link} href={href} className="block py-2">
{children}
</Popover.Button>
</li>
)
}
function MobileNavigation(props) {
return (
<Popover {...props}>
<Popover.Button
className="group flex items-center rounded-full bg-white/90 px-4 py-2 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 dark:hover:ring-white/20">
Menu
<ChevronDownIcon
className="ml-3 h-auto w-2 stroke-zinc-500 group-hover:stroke-zinc-700 dark:group-hover:stroke-zinc-400"/>
</Popover.Button>
<Transition.Root>
<Transition.Child
as={Fragment}
enter="duration-150 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-150 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Popover.Overlay className="fixed inset-0 z-50 bg-zinc-800/40 backdrop-blur-sm dark:bg-black/80"/>
</Transition.Child>
<Transition.Child
as={Fragment}
enter="duration-150 ease-out"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-150 ease-in"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Popover.Panel
focus
className="fixed inset-x-4 top-8 z-50 origin-top rounded-3xl bg-white p-8 ring-1 ring-zinc-900/5 dark:bg-zinc-900 dark:ring-zinc-800"
>
<div className="flex flex-row-reverse items-center justify-between">
<Popover.Button aria-label="Close menu" className="-m-1 p-1">
<CloseIcon className="h-6 w-6 text-zinc-500 dark:text-zinc-400"/>
</Popover.Button>
<h2 className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
Navigation
</h2>
</div>
<nav className="mt-6">
<ul className="-my-2 divide-y divide-zinc-100 text-base text-zinc-800 dark:divide-zinc-100/5 dark:text-zinc-300">
<MobileNavItem href="/about">About</MobileNavItem>
<MobileNavItem href="/writing">Writing</MobileNavItem>
<MobileNavItem href="/projects">Projects</MobileNavItem>
<MobileNavItem href="/uses">Uses</MobileNavItem>
</ul>
</nav>
</Popover.Panel>
</Transition.Child>
</Transition.Root>
</Popover>
)
}
function NavItem({href, children}) {
let isActive = useRouter().pathname === href
return (
<li>
<Link
href={href}
className={clsx(
'relative block px-3 py-2 transition',
isActive
? 'text-indigo-500 dark:text-indigo-400'
: 'hover:text-indigo-500 dark:hover:text-indigo-400'
)}
>
{children}
{isActive && (
<span
className="absolute inset-x-1 -bottom-px h-px bg-gradient-to-r from-indigo-500/0 via-indigo-500/40 to-indigo-500/0 dark:from-indigo-400/0 dark:via-indigo-400/40 dark:to-indigo-400/0"/>
)}
</Link>
</li>
)
}
function DesktopNavigation(props) {
return (
<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">
<NavItem href="/about">About</NavItem>
<NavItem href="/writing">Writing</NavItem>
<NavItem href="/projects">Projects</NavItem>
<NavItem href="/uses">Uses</NavItem>
</ul>
</nav>
)
}
function ModeToggle() {
function disableTransitionsTemporarily() {
document.documentElement.classList.add('[&_*]:!transition-none')
window.setTimeout(() => {
document.documentElement.classList.remove('[&_*]:!transition-none')
}, 0)
}
function toggleMode() {
disableTransitionsTemporarily()
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
let isSystemDarkMode = darkModeMediaQuery.matches
let isDarkMode = document.documentElement.classList.toggle('dark')
if (isDarkMode === isSystemDarkMode) {
delete window.localStorage.isDarkMode
} else {
window.localStorage.isDarkMode = isDarkMode
}
}
return (
<button
type="button"
aria-label="Toggle dark mode"
className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
onClick={toggleMode}
>
<SunIcon
className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-indigo-50 [@media(prefers-color-scheme:dark)]:stroke-indigo-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-indigo-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-indigo-600"/>
<MoonIcon
className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-indigo-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-indigo-500"/>
</button>
)
}
function clamp(number, a, b) {
let min = Math.min(a, b)
let max = Math.max(a, b)
return Math.min(Math.max(number, min), max)
}
function AvatarContainer({className, ...props}) {
return (
<div
className={clsx(
className,
'h-10 w-10 rounded-full bg-white/90 p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:ring-white/10'
)}
{...props}
/>
)
}
function Avatar({large = false, className, ...props}) {
return (
<Link
href="/"
aria-label="Home"
className={clsx(className, 'pointer-events-auto')}
{...props}
>
<Image
src={avatar}
alt=""
sizes={large ? '4rem' : '2.25rem'}
className={clsx(
'rounded-full bg-zinc-100 object-cover dark:bg-zinc-800',
large ? 'h-16 w-16' : 'h-9 w-9'
)}
priority
placeholder="blur"
/>
</Link>
)
}
export function Header() {
let isHomePage = useRouter().pathname === '/'
let headerRef = useRef()
let avatarRef = useRef()
let isInitial = useRef(true)
useEffect(() => {
let downDelay = avatarRef.current?.offsetTop ?? 0
let upDelay = 64
function setProperty(property, value) {
document.documentElement.style.setProperty(property, value)
}
function removeProperty(property) {
document.documentElement.style.removeProperty(property)
}
function updateHeaderStyles() {
let {top, height} = headerRef.current.getBoundingClientRect()
let scrollY = clamp(
window.scrollY,
0,
document.body.scrollHeight - window.innerHeight
)
if (isInitial.current) {
setProperty('--header-position', 'sticky')
}
setProperty('--content-offset', `${downDelay}px`)
if (isInitial.current || scrollY < downDelay) {
setProperty('--header-height', `${downDelay + height}px`)
setProperty('--header-mb', `${-downDelay}px`)
} else if (top + height < -upDelay) {
let offset = Math.max(height, scrollY - upDelay)
setProperty('--header-height', `${offset}px`)
setProperty('--header-mb', `${height - offset}px`)
} else if (top === 0) {
setProperty('--header-height', `${scrollY + height}px`)
setProperty('--header-mb', `${-scrollY}px`)
}
if (top === 0 && scrollY > 0 && scrollY >= downDelay) {
setProperty('--header-inner-position', 'fixed')
removeProperty('--header-top')
removeProperty('--avatar-top')
} else {
removeProperty('--header-inner-position')
setProperty('--header-top', '0px')
setProperty('--avatar-top', '0px')
}
}
function updateAvatarStyles() {
if (!isHomePage) {
return
}
let fromScale = 1
let toScale = 36 / 64
let fromX = 0
let toX = 2 / 16
let scrollY = downDelay - window.scrollY
let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale
scale = clamp(scale, fromScale, toScale)
let x = (scrollY * (fromX - toX)) / downDelay + toX
x = clamp(x, fromX, toX)
setProperty(
'--avatar-image-transform',
`translate3d(${x}rem, 0, 0) scale(${scale})`
)
let borderScale = 1 / (toScale / scale)
let borderX = (-toX + x) * borderScale
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
setProperty('--avatar-border-transform', borderTransform)
setProperty('--avatar-border-opacity', scale === toScale ? 1 : 0)
}
function updateStyles() {
updateHeaderStyles()
updateAvatarStyles()
isInitial.current = false
}
updateStyles()
window.addEventListener('scroll', updateStyles, {passive: true})
window.addEventListener('resize', updateStyles)
return () => {
window.removeEventListener('scroll', updateStyles, {passive: true})
window.removeEventListener('resize', updateStyles)
}
}, [isHomePage])
return (
<>
<header
className="pointer-events-none relative z-50 flex flex-col"
style={{
height: 'var(--header-height)',
marginBottom: 'var(--header-mb)',
}}
>
{isHomePage && (
<>
<div
ref={avatarRef}
className="order-last mt-[calc(theme(spacing.16)-theme(spacing.3))]"
/>
<Container
className="top-0 order-last -mb-3 pt-3"
style={{position: 'var(--header-position)'}}
>
<div
className="top-[var(--avatar-top,theme(spacing.3))] w-full"
style={{position: 'var(--header-inner-position)'}}
>
<div className="relative">
<AvatarContainer
className="absolute left-0 top-3 origin-left transition-opacity"
style={{
opacity: 'var(--avatar-border-opacity, 0)',
transform: 'var(--avatar-border-transform)',
}}
/>
<Avatar
large
className="block h-16 w-16 origin-left"
style={{transform: 'var(--avatar-image-transform)'}}
/>
</div>
</div>
</Container>
</>
)}
<div
ref={headerRef}
className="top-0 z-10 h-16 pt-6"
style={{position: 'var(--header-position)'}}
>
<Container
className="top-[var(--header-top,theme(spacing.6))] w-full"
style={{position: 'var(--header-inner-position)'}}
>
<div className="relative flex gap-4">
<div className="flex flex-1">
{!isHomePage && (
<AvatarContainer>
<Avatar/>
</AvatarContainer>
)}
</div>
<div className="flex flex-1 justify-end md:justify-center">
<MobileNavigation className="pointer-events-auto md:hidden"/>
<DesktopNavigation className="pointer-events-auto hidden md:block"/>
</div>
<div className="flex justify-end md:flex-1">
<div className="pointer-events-auto">
<ModeToggle/>
</div>
</div>
</div>
</Container>
</div>
</header>
{isHomePage && <div style={{height: 'var(--content-offset)'}}/>}
</>
)
}

7
src/components/Prose.jsx Normal file
View File

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

View File

@ -0,0 +1,22 @@
import { useId } from 'react'
export function Section({ title, children }) {
let id = useId()
return (
<section
aria-labelledby={id}
className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40"
>
<div className="grid max-w-3xl grid-cols-1 items-baseline gap-y-8 md:grid-cols-4">
<h2
id={id}
className="text-sm font-semibold text-zinc-800 dark:text-zinc-100"
>
{title}
</h2>
<div className="md:col-span-3">{children}</div>
</div>
</section>
)
}

View File

@ -0,0 +1,21 @@
import {Container} from '@/components/Container'
import clsx from 'clsx'
export function SimpleLayout({title, intro, children, gradient}) {
return (
<Container className="mt-16 sm:mt-32">
<header className="max-w-2xl">
<h1
className={clsx(gradient
? `${gradient} bg-clip-text dark:text-transparent`
: '', 'text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl')}>
{title}
</h1>
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
{intro}
</p>
</header>
<div className="mt-16 sm:mt-20">{children}</div>
</Container>
)
}

View File

@ -0,0 +1,36 @@
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,164 @@
import useSWR from 'swr'
import fetcher from '@/lib/fetcher'
import Image from 'next/future/image'
import Link from 'next/link'
import clsx from 'clsx'
import {useEffect} from 'react'
import {animate} from 'motion'
function AnimatedBars() {
useEffect(() => {
animate(
'#bar1',
{
transform: [
'scaleY(1.0) translateY(0rem)',
'scaleY(1.5) translateY(-0.082rem)',
'scaleY(1.0) translateY(0rem)'
]
},
{
duration: 1.0,
repeat: Infinity,
easing: ['ease-in-out']
}
);
animate(
'#bar2',
{
transform: [
'scaleY(1.0) translateY(0rem)',
'scaleY(3) translateY(-0.083rem)',
'scaleY(1.0) translateY(0rem)'
]
},
{
delay: 0.2,
duration: 1.5,
repeat: Infinity,
easing: ['ease-in-out']
}
);
animate(
'#bar3',
{
transform: [
'scaleY(1.0) translateY(0rem)',
'scaleY(0.5) translateY(0.37rem)',
'scaleY(1.0) translateY(0rem)'
]
},
{
delay: 0.3,
duration: 1.5,
repeat: Infinity,
easing: ['ease-in-out']
}
);
}, [])
return (
<div className="w-auto flex items-end overflow-hidden flex-shrink-0">
<span
id="bar1"
className="w-1 mr-[3px] h-2 bg-gray-300 dark:bg-green-950"
/>
<span
id="bar2"
className="w-1 mr-[3px] h-1 bg-gray-300 dark:bg-green-950"
/>
<span
id="bar3"
className="w-1 h-3 bg-gray-300 dark:bg-green-950"
/>
</div>
)
}
function usePlayerState(path, options) {
const {data, error, isLoading} = useSWR(`/api/spotify/${path}`, fetcher, options)
return {
song: data,
isLoading,
isError: error
}
}
function Song({artist, title, songUrl, album, albumImageUrl, isPlaying}) {
return (
<div className="flex items-center space-x-4">
{isPlaying &&
<AnimatedBars/>
}
<Image
width="64"
height="64"
alt={album}
src={albumImageUrl}
className="aspect-square rounded-2xl object-cover"
/>
<div>
<h2 className={clsx(isPlaying ? 'dark:text-green-950' : 'dark:text-zinc-100', 'text-sm font-semibold text-zinc-800')}>
<Link href={songUrl}>
{title}
</Link>
</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none">{artist}</p>
</div>
</div>
)
}
function SongSkeleton() {
return (
<div className="flex items-center space-x-4 animate-pulse">
<div
className="w-[64px] h-[64px] bg-zinc-100 rounded-2xl dark:bg-zinc-900"
/>
<div>
<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"/>
</div>
</div>
)
}
function CurrentlyPlaying(props) {
return (
<Song {...props}/>
)
}
function LastPlayed() {
const {song, isLoading, isError} = usePlayerState('last-played')
if (isError) return
return (
<>
{isLoading
? <SongSkeleton/>
: song?.title &&
<Song {...song} />
}
</>
)
}
export function SpotifyPlayer() {
const {song, isLoading, isError} = usePlayerState('currently-playing', {refreshInterval: 30000})
if (isError) return
return (
<div className="grid place-items-center sm:place-items-start">
{isLoading
? <SongSkeleton/>
: song?.isPlaying
? <CurrentlyPlaying {...song}/>
: <LastPlayed/>
}
</div>
)
}

BIN
src/images/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

3
src/lib/fetcher.js Normal file
View File

@ -0,0 +1,3 @@
const fetcher = (...args) => fetch(...args).then(res => res.json())
export default fetcher

8
src/lib/formatDate.js Normal file
View File

@ -0,0 +1,8 @@
export function formatDate(dateString) {
return new Date(`${dateString}T00:00:00Z`).toLocaleDateString('en-IE', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
})
}

View File

@ -0,0 +1,53 @@
import ReactDOMServer from 'react-dom/server'
import {Feed} from 'feed'
import {mkdir, writeFile} from 'fs/promises'
import {getAllArticles} from './getAllArticles'
export async function generateRssFeed() {
let articles = await getAllArticles()
let siteUrl = process.env.NEXT_PUBLIC_SITE_URL
let author = {
name: 'Ryan Freeman',
email: 'hello@ryanfreeman.dev',
}
let feed = new Feed({
title: author.name,
description: 'Hi. I\'m Ryan, a software engineer based in Dublin, Ireland. I\'m currently working in the aviation industry for Aer Lingus. I am passionate about personal growth and progressing in my career. This is my personal website where you can learn more about me, read articles Ive written and see projects I\'ve worked on.',
author,
id: siteUrl,
link: siteUrl,
image: `${siteUrl}/static/icons/favicon.ico`,
favicon: `${siteUrl}/static/icons/favicon.ico`,
copyright: `All rights reserved ${new Date().getFullYear()}`,
feedLinks: {
rss2: `${siteUrl}/rss/feed.xml`,
json: `${siteUrl}/rss/feed.json`,
},
})
for (let article of articles) {
let url = `${siteUrl}/writing/${article.slug}`
let html = ReactDOMServer.renderToStaticMarkup(
<article.component isRssFeed/>
)
feed.addItem({
title: article.title,
id: url,
link: url,
description: article.description,
content: html,
author: [author],
contributor: [author],
date: new Date(article.date),
})
}
await mkdir('./public/rss', {recursive: true})
await Promise.all([
writeFile('./public/rss/feed.xml', feed.rss2(), 'utf8'),
writeFile('./public/rss/feed.json', feed.json1(), 'utf8'),
])
}

23
src/lib/getAllArticles.js Normal file
View File

@ -0,0 +1,23 @@
import glob from 'fast-glob'
import * as path from 'path'
async function importArticle(articleFilename) {
let { meta, default: component } = await import(
`../pages/writing/${articleFilename}`
)
return {
slug: articleFilename.replace(/(\/index)?\.mdx$/, ''),
...meta,
component,
}
}
export async function getAllArticles() {
let articleFilenames = await glob(['*.mdx', '*/index.mdx'], {
cwd: path.join(process.cwd(), 'src/pages/writing'),
})
let articles = await Promise.all(articleFilenames.map(importArticle))
return articles.sort((a, z) => new Date(z.date) - new Date(a.date))
}

46
src/lib/spotify.js Normal file
View File

@ -0,0 +1,46 @@
import fetch from 'node-fetch'
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET
const SPOTIFY_REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN
const SPOTIFY_TOKEN = "https://accounts.spotify.com/api/token"
const SPOTIFY_CURRENTLY_PLAYING = "https://api.spotify.com/v1/me/player/currently-playing"
const SPOTIFY_RECENTLY_PLAYED = "https://api.spotify.com/v1/me/player/recently-played"
const basic = Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')
const getAccessToken = async () => {
const response = await fetch(SPOTIFY_TOKEN, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
'grant_type': 'refresh_token',
'refresh_token': `${SPOTIFY_REFRESH_TOKEN}`
})
})
return response.json()
}
export const getCurrentlyPlaying = async () => {
const {access_token} = await getAccessToken()
return await fetch(SPOTIFY_CURRENTLY_PLAYING, {
headers: {
Authorization: `Bearer ${access_token}`
}
})
}
export const getRecentlyPlayed = async () => {
const {access_token} = await getAccessToken()
return await fetch(SPOTIFY_RECENTLY_PLAYED, {
headers: {
Authorization: `Bearer ${access_token}`
}
})
}

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

@ -0,0 +1,38 @@
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>
</>
)
}

76
src/pages/_document.jsx Normal file
View File

@ -0,0 +1,76 @@
import {Head, Html, Main, NextScript} from 'next/document'
const modeScript = `
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
updateMode()
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
window.addEventListener('storage', updateModeWithoutTransitions)
function updateMode() {
let isSystemDarkMode = darkModeMediaQuery.matches
let isDarkMode = window.localStorage.isDarkMode === 'true' || (!('isDarkMode' in window.localStorage) && isSystemDarkMode)
if (isDarkMode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
if (isDarkMode === isSystemDarkMode) {
delete window.localStorage.isDarkMode
}
}
function disableTransitionsTemporarily() {
document.documentElement.classList.add('[&_*]:!transition-none')
window.setTimeout(() => {
document.documentElement.classList.remove('[&_*]:!transition-none')
}, 0)
}
function updateModeWithoutTransitions() {
disableTransitionsTemporarily()
updateMode()
}
`
export default function Document() {
return (
<Html className="h-full antialiased" lang="en">
<Head>
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
<link
rel="alternate"
type="application/rss+xml"
href="/rss/feed.xml"
/>
<link
rel="alternate"
type="application/feed+json"
href="/rss/feed.json"
/>
<link
rel="icon"
type="image/png"
href="/static/icons/favicon-16x16.png"
sizes="16x16"
/>
<link
rel="icon"
type="image/png"
href="/static/icons/favicon-32x32.png"
sizes="32x32"
/>
<link
rel="apple-touch-icon"
href="/static/icons/apple-touch-icon.png"
/>
</Head>
<body className="flex h-full flex-col dark:bg-black">
<Main/>
<NextScript/>
</body>
</Html>
)
}

146
src/pages/about.jsx Normal file
View File

@ -0,0 +1,146 @@
import Image from 'next/future/image'
import Head from 'next/head'
import Link from 'next/link'
import clsx from 'clsx'
import {Container} from '@/components/Container'
import {
GitHubIcon,
LinkedInIcon,
TwitterIcon
} from '@/components/SocialIcons'
import photoOfMeLg from '@/images/photo-of-me-lg.jpg'
import awsCCPBadge from '@/images/aws-certified-cloud-practitioner-badge.png'
function SocialLink({className, href, children, icon: Icon}) {
return (
<li className={clsx(className, 'flex')}>
<Link
href={href}
className="group flex text-sm font-medium text-zinc-800 transition hover:text-indigo-500 dark:text-zinc-200 dark:hover:text-indigo-500"
>
<Icon className="h-6 w-6 flex-none fill-zinc-500 transition group-hover:fill-indigo-500"/>
<span className="ml-4">{children}</span>
</Link>
</li>
)
}
function MailIcon(props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
d="M6 5a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3H6Zm.245 2.187a.75.75 0 0 0-.99 1.126l6.25 5.5a.75.75 0 0 0 .99 0l6.25-5.5a.75.75 0 0 0-.99-1.126L12 12.251 6.245 7.187Z"
/>
</svg>
)
}
export default function About() {
return (
<>
<Head>
<title>About - Ryan Freeman</title>
<meta
name="description"
content="Im Ryan. I live in Dublin, Ireland where I work as a software engineer."
/>
<meta
property="og:title"
content="About - Ryan Freeman"
/>
<meta
property="og:description"
content="Im Ryan. I live in Dublin, Ireland where I work as a software engineer."
/>
</Head>
<Container className="mt-16 sm:mt-32">
<div className="grid grid-cols-1 gap-y-16 lg:grid-cols-2 lg:grid-rows-[auto_1fr] lg:gap-y-12">
<div className="lg:pl-20">
<div className="max-w-xs px-2.5 lg:max-w-none">
<Image
src={photoOfMeLg}
alt=""
sizes="(min-width: 1024px) 32rem, 20rem"
className="aspect-square shadow-inner rounded-2xl bg-zinc-100 object-cover dark:bg-zinc-800 rotate-3"
placeholder="blur"
/>
</div>
</div>
<div className="lg:order-first lg:row-span-2">
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-blue-400 to-emerald-400">
Im Ryan. I live in Dublin, Ireland where I work as a software engineer.
</h1>
<div className="mt-6 space-y-7 text-base text-zinc-600 dark:text-zinc-400">
<p>
I&apos;ve always had an affinity for technology, and loved making things for as long as I can
remember. My first computer was an Amstrad CPC 464 way back in the 90s, which is ancient by modern
standards. My passion for tinkering continued through my teens and into adulthood where I
eventually found my way into software engineering.
</p>
<p>
In terms of my experience to date, I have a strong foundation in both front-end and back-end
development. I enjoy working with React to create dynamic and response user interfaces,
and I have a deep understanding of Java for building powerful and scalable applications.
Recently, I achieved one of my milestones which was to get AWS certified by the end of 2022.
</p>
<p>
Currently, I work in the aviation industry for Aer Lingus as a software engineer where I work
on exciting software projects for the airline. This includes everything from bug fixing, to
working on legacy code and greenfield projects, to building customer-facing websites and services.
I am responsible for ensuring that our software is of the highest quality, and that it meets the
needs of our customers and stakeholders. The most fulfilling part of my job is knowing that the
software I contribute to will be used by many thousands of people.
</p>
<p>
In my free time, I enjoy staying up-to-date on the latest developments in the world of software
engineering, and I am always looking for new ways to push the boundaries of what is possible with
technology. I&apos;m a huge advocate of free and open-source software and maintain a small
Raspberry Pi server which I use to experiment with Docker containers for self-hosted
services like Bitwarden, Nextcloud and Octoprint.
</p>
<p>
On the hardware side, I build and maintain my own computers and I like to upgrade and modernise
retro video game systems. When I&apos;m not tinkering, I mostly spend time with my
family and enjoy travelling whenever I can get away.
</p>
<p>
That&apos;s me in a nutshell, thank you for visiting my website, I hope that you find the
information here to be insightful. If you have any questions or would like to work with me, please
don&apos;t hesitate to get in touch.
</p>
</div>
</div>
<div className="lg:pl-20">
<ul role="list">
<SocialLink href="https://github.com/r-freeman" icon={GitHubIcon} className="mt-4">
Follow on GitHub
</SocialLink>
<SocialLink href="https://linkedin.com/in/r-freeman/" icon={LinkedInIcon} className="mt-4">
Follow on LinkedIn
</SocialLink>
<SocialLink href="https://twitter.com/freemry" icon={TwitterIcon} className="mt-4">
Follow on Twitter
</SocialLink>
<SocialLink
href="mailto:hello@ryanfreeman.dev"
icon={MailIcon}
className="mt-8 border-t border-zinc-100 pt-8 dark:border-zinc-700/40"
>
hello@ryanfreeman.dev
</SocialLink>
</ul>
<Image
src={awsCCPBadge}
width="170"
height="170"
alt="AWS Certified Cloud Practitioner"
className="mt-8"
/>
</div>
</div>
</Container>
</>
)
}

View File

@ -0,0 +1,36 @@
import {getCurrentlyPlaying} from '@/lib/spotify'
export default async function handler(req, res) {
const response = await getCurrentlyPlaying()
if (response.status === 204 || response.status > 400) {
return res.status(200).json({
isPlaying: false
})
}
const song = await response.json()
const {item} = song
if (item === null) {
return res.status(200).json({
isPlaying: false
})
}
const artist = item.artists.map(artist => artist.name).join(', ')
const title = item.name;
const songUrl = item.external_urls.spotify
const album = item.album.name
const albumImageUrl = item.album.images[0].url
const isPlaying = song.is_playing;
return res.status(200).json({
artist,
title,
songUrl,
album,
albumImageUrl,
isPlaying
})
}

View File

@ -0,0 +1,31 @@
import {getRecentlyPlayed} from '@/lib/spotify'
export default async function handler(req, res) {
const response = await getRecentlyPlayed()
if (response.status > 400) {
return res.status(200).json({})
}
const tracks = await response.json();
if (tracks === null) {
return res.status(200).json({})
}
const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a)
const title = track.name;
const artist = track.artists.map(artist => artist.name).join(', ')
const songUrl = track.external_urls.spotify
const album = track.album.name
const albumImageUrl = track.album.images[0].url
return res.status(200).json({
artist,
title,
songUrl,
album,
albumImageUrl
})
}

252
src/pages/index.jsx Normal file
View File

@ -0,0 +1,252 @@
import Head from 'next/head'
import Link from 'next/link'
import {Button} from '@/components/Button'
import {Card} from '@/components/Card'
import {Container} from '@/components/Container'
import {
GitHubIcon,
LinkedInIcon,
TwitterIcon
} from '@/components/SocialIcons'
import {generateRssFeed} from '@/lib/generateRssFeed'
import {getAllArticles} from '@/lib/getAllArticles'
import {formatDate} from '@/lib/formatDate'
function BriefcaseIcon(props) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<path
d="M2.75 9.75a3 3 0 0 1 3-3h12.5a3 3 0 0 1 3 3v8.5a3 3 0 0 1-3 3H5.75a3 3 0 0 1-3-3v-8.5Z"
className="fill-zinc-100 stroke-zinc-400 dark:fill-zinc-100/10 dark:stroke-zinc-500"
/>
<path
d="M3 14.25h6.249c.484 0 .952-.002 1.316.319l.777.682a.996.996 0 0 0 1.316 0l.777-.682c.364-.32.832-.319 1.316-.319H21M8.75 6.5V4.75a2 2 0 0 1 2-2h2.5a2 2 0 0 1 2 2V6.5"
className="stroke-zinc-400 dark:stroke-zinc-500"
/>
</svg>
)
}
function ArrowDownIcon(props) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path
d="M4.75 8.75 8 12.25m0 0 3.25-3.5M8 12.25v-8.5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function Article({article}) {
return (
<Card as="article">
<Card.Title href={`/writing/${article.slug}`}>
{article.title}
</Card.Title>
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
{formatDate(article.date)}
</Card.Eyebrow>
<Card.Description>{article.description}</Card.Description>
<Card.Cta>Read more</Card.Cta>
</Card>
)
}
function SocialLink({icon: Icon, ...props}) {
return (
<Link className="group -m-1 p-1" {...props}>
<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"/>
</Link>
)
}
function Resume() {
let work = [
{
company: 'Aer Lingus',
title: 'Software engineer',
start: '2022',
end: {
label: 'present',
dateTime: new Date().getFullYear(),
},
},
{
company: 'Apple',
title: 'At home advisor',
start: '2014',
end: '2018',
}
]
return (
<div className="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40 -mt-6">
<h2 className="flex text-sm font-semibold text-zinc-900 dark:text-zinc-100">
<BriefcaseIcon className="h-6 w-6 flex-none"/>
<span className="ml-3">Work</span>
</h2>
<ol className="mt-6 space-y-4">
{work.map((role, roleIndex) => (
<li key={roleIndex} className="flex gap-4">
<dl className="flex flex-auto flex-wrap gap-x-2">
<dt className="sr-only">Company</dt>
<dd className="w-full flex-none text-sm font-medium text-zinc-900 dark:text-zinc-100">
{role.company}
</dd>
<dt className="sr-only">Role</dt>
<dd className="text-xs text-zinc-500 dark:text-zinc-400">
{role.title}
</dd>
<dt className="sr-only">Date</dt>
<dd
className="ml-auto text-xs text-zinc-500 dark:text-zinc-400"
aria-label={`${role.start.label ?? role.start} until ${
role.end.label ?? role.end
}`}
>
<time dateTime={role.start.dateTime ?? role.start}>
{role.start.label ?? role.start}
</time>
<span aria-hidden="true"></span>
<time dateTime={role.end.dateTime ?? role.end}>
{role.end.label ?? role.end}
</time>
</dd>
</dl>
</li>
))}
</ol>
<Button href="/Ryan Freeman CV.pdf" variant="secondary" className="group mt-6 w-full">
Download CV
<ArrowDownIcon
className="h-4 w-4 stroke-zinc-400 transition group-active:stroke-zinc-600 dark:group-hover:stroke-zinc-50 dark:group-active:stroke-zinc-50"/>
</Button>
</div>
)
}
export default function Home({articles}) {
return (
<>
<Head>
<title>
Ryan Freeman - Full-stack software engineer from Dublin, Ireland.
</title>
<meta
name="description"
content="Full-stack software engineer who enjoys building cloud-native applications."
/>
<meta
property="og:url"
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
/>
<meta
property="og:type"
content="website"
/>
<meta
property="og:title"
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."
/>
<meta
property="og:description"
content="Full-stack software engineer who enjoys building cloud-native applications."
/>
<meta
property="og:image"
content="/static/images/photo-of-me-lg.jpg"
/>
<meta
name="twitter:card"
content="summary_large_image"/>
<meta
property="twitter:domain"
content="ryanfreeman.dev"/>
<meta
property="twitter:url"
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
/>
<meta
name="twitter:title"
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."/>
<meta
name="twitter:description"
content="Full-stack software engineer who enjoys building cloud-native applications."/>
<meta
name="twitter:image"
content="/static/images/photo-of-me-lg.jpg"
/>
</Head>
<Container className="mt-9">
<div className="max-w-2xl">
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500">
Full-stack software engineer who enjoys building cloud-native applications.
</h1>
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
Hi. I&apos;m Ryan, a software engineer based in Dublin, Ireland. I&apos;m currently working in the
aviation industry for Aer Lingus. I am passionate about personal growth and progressing in my career.
This is my personal website where you can learn more about me, read
articles I&apos;ve written and see projects I&apos;ve worked on.
</p>
<div className="mt-6 flex gap-6">
<SocialLink
href="https://github.com/r-freeman"
aria-label="Follow on GitHub"
icon={GitHubIcon}
/>
<SocialLink
href="https://linkedin.com/in/r-freeman/"
aria-label="Follow on LinkedIn"
icon={LinkedInIcon}
/>
<SocialLink
href="https://twitter.com/freemry"
aria-label="Follow on Twitter"
icon={TwitterIcon}
/>
</div>
</div>
</Container>
<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="flex flex-col gap-16">
{articles.map((article) => (
<Article key={article.slug} article={article}/>
))}
</div>
<div className="space-y-10 lg:pl-16 xl:pl-24">
<Resume/>
</div>
</div>
</Container>
</>
)
}
export async function getStaticProps() {
if (process.env.NODE_ENV === 'production') {
await generateRssFeed()
}
return {
props: {
articles: (await getAllArticles())
.slice(0, 2)
.map(({component, ...meta}) => meta),
},
}
}

93
src/pages/projects.jsx Normal file
View File

@ -0,0 +1,93 @@
import Head from 'next/head'
import {Card} from '@/components/Card'
import {SimpleLayout} from '@/components/SimpleLayout'
const projects = [
{
name: 'Portfolio',
description:
'This is my personal website built using Tailwind and Next.js, it is a customised version of the Spotlight template from Tailwind UI.',
link: {href: 'https://github.com/r-freeman/portfolio', label: 'r-freeman/portfolio'},
},
{
name: 'Intellagent',
description:
'Intellagent is a cloud-based help desk software solution for small business, powered by AI and machine learning.',
link: {href: 'https://github.com/r-freeman/intellagent-server', label: 'r-freeman/intellagent-server'},
},
{
name: 'Super Mario Run',
description:
'Mobile game inspired by Super Mario Bros, built with JavaScript and Phaser.',
link: {href: 'https://github.com/r-freeman/super-mario-run', label: 'r-freeman/super-mario-run'},
},
{
name: 'ASOS Prototype',
description:
'Working prototype of ASOS\' website built in Axure RP 8.',
link: {href: 'https://github.com/r-freeman/ASOS-Axure-Prototype', label: 'r-freeman/ASOS-Axure-Prototype'},
},
{
name: 'College App',
description:
'Full-stack application built with Laravel 6, Vue.js and Tailwind CSS.',
link: {href: 'https://github.com/r-freeman/college-app', label: 'r-freeman/college-app'},
}
]
function LinkIcon(props) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
d="M15.712 11.823a.75.75 0 1 0 1.06 1.06l-1.06-1.06Zm-4.95 1.768a.75.75 0 0 0 1.06-1.06l-1.06 1.06Zm-2.475-1.414a.75.75 0 1 0-1.06-1.06l1.06 1.06Zm4.95-1.768a.75.75 0 1 0-1.06 1.06l1.06-1.06Zm3.359.53-.884.884 1.06 1.06.885-.883-1.061-1.06Zm-4.95-2.12 1.414-1.415L12 6.344l-1.415 1.413 1.061 1.061Zm0 3.535a2.5 2.5 0 0 1 0-3.536l-1.06-1.06a4 4 0 0 0 0 5.656l1.06-1.06Zm4.95-4.95a2.5 2.5 0 0 1 0 3.535L17.656 12a4 4 0 0 0 0-5.657l-1.06 1.06Zm1.06-1.06a4 4 0 0 0-5.656 0l1.06 1.06a2.5 2.5 0 0 1 3.536 0l1.06-1.06Zm-7.07 7.07.176.177 1.06-1.06-.176-.177-1.06 1.06Zm-3.183-.353.884-.884-1.06-1.06-.884.883 1.06 1.06Zm4.95 2.121-1.414 1.414 1.06 1.06 1.415-1.413-1.06-1.061Zm0-3.536a2.5 2.5 0 0 1 0 3.536l1.06 1.06a4 4 0 0 0 0-5.656l-1.06 1.06Zm-4.95 4.95a2.5 2.5 0 0 1 0-3.535L6.344 12a4 4 0 0 0 0 5.656l1.06-1.06Zm-1.06 1.06a4 4 0 0 0 5.657 0l-1.061-1.06a2.5 2.5 0 0 1-3.535 0l-1.061 1.06Zm7.07-7.07-.176-.177-1.06 1.06.176.178 1.06-1.061Z"
fill="currentColor"
/>
</svg>
)
}
export default function Projects() {
return (
<>
<Head>
<title>Projects - Ryan Freeman</title>
<meta
name="description"
content="Things I've made and projects I've worked on."
/>
<meta
property="og:title"
content="Projects - Ryan Freeman"
/>
<meta
property="og:description"
content="Things I've made and projects I've worked on."
/>
</Head>
<SimpleLayout
title="Things I've made and projects I've worked on."
intro="Here's a selection of academic and personal projects that I have worked on. Many of them are open-source, so if you see something that piques your interest, check out the code and contribute if you have ideas for how it can be improved."
gradient="bg-gradient-to-r from-sky-400 to-blue-500"
>
<ul
role="list"
className="grid grid-cols-1 gap-x-12 gap-y-16 sm:grid-cols-2 lg:grid-cols-3"
>
{projects.map((project) => (
<Card as="li" key={project.name}>
<h2 className="text-base font-semibold text-zinc-800 dark:text-zinc-100">
<Card.Link href={project.link.href}>{project.name}</Card.Link>
</h2>
<Card.Description>{project.description}</Card.Description>
<p className="relative z-10 mt-6 flex text-sm font-medium text-zinc-400 transition group-hover:text-indigo-500 dark:text-zinc-200">
<LinkIcon className="h-6 w-6 flex-none"/>
<span className="ml-2">{project.link.label}</span>
</p>
</Card>
))}
</ul>
</SimpleLayout>
</>
)
}

117
src/pages/uses.jsx Normal file
View File

@ -0,0 +1,117 @@
import Head from 'next/head'
import {Card} from '@/components/Card'
import {Section} from '@/components/Section'
import {SimpleLayout} from '@/components/SimpleLayout'
import Link from "next/link";
function ToolsSection({children, ...props}) {
return (
<Section {...props}>
<ul role="list" className="space-y-16">
{children}
</ul>
</Section>
)
}
function Tool({title, href, children}) {
return (
<Card as="li">
<Card.Title as="h3" href={href}>
{title}
</Card.Title>
<Card.Description>{children}</Card.Description>
</Card>
)
}
export default function Uses() {
return (
<>
<Head>
<title>Uses - Ryan Freeman</title>
<meta
name="description"
content="Software I use, equipment that makes my job easier, and other things I recommend."
/>
<meta
property="og:title"
content="Uses - Ryan Freeman"
/>
<meta
property="og:description"
content="Software I use, equipment that makes my job easier, and other things I recommend."
/>
</Head>
<SimpleLayout
title="Software I use, equipment that makes my job easier, and other things I recommend."
intro="I get asked a lot about the things I use to build software and stay productive. Heres a big list of all of my favourite gear."
gradient="bg-gradient-to-r from-orange-400 to-rose-400"
>
<div className="space-y-20">
<ToolsSection title="Workstation">
<Tool title="PC Build 2022" href="https://pcpartpicker.com/b/KXfPxr">
This is my main Intel-based computer which I built in 2022. This build is composed of some new and old
parts which I have gathered over the years. Click here to see a comprehensive listing of all the parts
used in this build on PCPartPicker.
</Tool>
<Tool title="Herman Miller Aeron Chair"
href="https://hermanmiller.com/en_eur/products/seating/office-chairs/aeron-chairs/">
I bought this chair second-hand when I started working for Apple, it&apos;s extremely comfortable and
ergonomic for those long hours spent at the desk.
</Tool>
</ToolsSection>
<ToolsSection title="Development tools">
<Tool title="JetBrains" href="https://jetbrains.com/">
I use a mix of JetBrain apps for my IDEs depending on what I&apos;m working on. For JavaScript
projects, I use WebStorm. PyCharm for python and IntelliJ IDEA Ultimate for Java. I use the same
keyboard shortcuts across these apps which is great for productivity.
</Tool>
<Tool title="Insomnia" href="https://insomnia.rest/">
Good tool for designing and testing REST APIs. I used to use Postman but I found the interface too
cluttered and prefer the simplicity of Insomnia.
</Tool>
</ToolsSection>
<ToolsSection title="Design">
<Tool title="Balsamiq Wireframes" href="https://balsamiq.com/">
I use this software for creating low-fidelity wireframes and interfaces. It&apos;s great for
experimenting with ideas.
</Tool>
<Tool title="Color Picker" href="https://learn.microsoft.com/en-us/windows/powertoys/color-picker">
Color Picker is included in the PowerToys set of enhancements for Windows. Using the eye dropper you
can easily identify colours on the screen and copy the colour&apos;s code to your clipboard for use
in other applications.
</Tool>
</ToolsSection>
<ToolsSection title="Automation">
<Tool title="AutoHotKey" href="https://autohotkey.com/">
AutoHotKey features it&apos;s own scripting language and allows you to create keyboard macros for
automating common tasks. For example, I use AutoHotKey to toggle between dark and light themes in
Windows on the fly.
</Tool>
</ToolsSection>
<ToolsSection title="Chrome Extensions">
<Tool title="Bitwarden"
href="https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb">
Bitwarden is a free, open-source password manager, this Chrome Extension connects to a self-hosted
instance of Bitwarden which lives on my Raspberry Pi. It is really useful for syncing passwords across
devices.
</Tool>
<Tool title="uBlock Origin"
href="https://chrome.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">
Great extension for blocking those annoying YouTube ads and nasty tracking scripts.
</Tool>
<Tool title="Floccus"
href="https://chrome.google.com/webstore/detail/floccus-bookmarks-sync/fnaicdffflnofjppbagibeoednhnbjhg">
Floccus syncs your bookmarks across browsers and devices. It connects to my Nextcloud server via
WebDAV and keeps my bookmarks in sync, so no matter which device I&apos;m using, I always have the
same set of bookmarks.
</Tool>
</ToolsSection>
</div>
</SimpleLayout>
</>
)
}

View File

@ -0,0 +1,39 @@
import {ArticleLayout} from '@/components/ArticleLayout'
import Image from 'next/future/image'
import forestForkingPath from './jens-lelie-u0vgcIOQG08-unsplash.jpg'
export const meta = {
author: 'Ryan Freeman',
date: '2022-12-04',
title: 'A personal journey in software engineering',
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.',
ogImage: `/static/images/jens-lelie-u0vgcIOQG08-unsplash.jpg`
}
export default (props) => <ArticleLayout meta={meta} {...props} />
<Image
src={forestForkingPath}
alt="Photo by Jens Lelie on Unsplash"
placeholder="blur"
/>
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.
I have been working in the aviation industry for Aer Lingus since February 2022. My specialities include React, Java
and AWS technologies, and I have a strong focus on building scalable and reliable solutions.
This website is a showcase of my work, as well as a platform for me to share my thoughts and insights in the world of software engineering.
Here's what you can expect to find:
- A portfolio of my past and current projects, showcasing the range of applications I have developed and the technologies I have used.
- Blog posts on a variety of topics, from the latest trends in software engineering to my personal experiences and insights as a full-stack developer.
- Links to connect with me and to download my most up-to-date CV.
I hope that my website provides you with a glimpse into my world as a full-stack developer, and I invite you to explore and learn more about what I do.
Thanks for visiting, and I hope to hear from you soon!

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

View File

@ -0,0 +1,36 @@
import {ArticleLayout} from '@/components/ArticleLayout'
export const meta = {
author: 'Ryan Freeman',
date: '2023-01-02',
title: 'How to add TypeScript to an existing Next.js project',
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} />
# 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.
However, you should add an entry in your `.gitignore` for _next-env.d.ts_ so it is ignored by version control.
```json
"include": [
"next-env.d.ts",
"additional.d.ts"
"**/*.ts",
"**/*.tsx"
]
```
If you need to add additional types, create a _additional.d.ts_ file in your project root and reference it in the `include` array in _tsconfig.json_.
```json
"strict": true
```
For experienced TypeScript developers, you can enable `strict` mode by editing _tsconfig.json_, this setting is disabled by default.
That's it! Your Next.js project is ready to leverage the power of TypeScript.

View File

@ -0,0 +1,78 @@
import Head from 'next/head'
import {Card} from '@/components/Card'
import {SimpleLayout} from '@/components/SimpleLayout'
import {getAllArticles} from '@/lib/getAllArticles'
import {formatDate} from '@/lib/formatDate'
function Article({article}) {
return (
<article className="md:grid md:grid-cols-4 md:items-baseline">
<Card className="md:col-span-3">
<Card.Title href={`/writing/${article.slug}`}>
{article.title}
</Card.Title>
<Card.Eyebrow
as="time"
dateTime={article.date}
className="md:hidden"
decorate={false}
>
{formatDate(article.date)}
</Card.Eyebrow>
<Card.Description>{article.description}</Card.Description>
<Card.Cta>Read article</Card.Cta>
</Card>
<Card.Eyebrow
as="time"
dateTime={article.date}
className="mt-1 hidden md:block"
>
{formatDate(article.date)}
</Card.Eyebrow>
</article>
)
}
export default function ArticlesIndex({articles}) {
return (
<>
<Head>
<title>Writing - Ryan Freeman</title>
<meta
name="description"
content="Writing on software engineering, and everything in between."
/>
<meta
property="og:title"
content="Writing - Ryan Freeman"
/>
<meta
property="og:description"
content="Writing on software engineering, and everything in between."
/>
</Head>
<SimpleLayout
title="Writing on software engineering, and everything in between."
intro="All of my long-form thoughts on software engineering, and more, displayed in chronological order."
gradient="bg-gradient-to-r from-pink-500 to-violet-500"
>
<div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
<div className="flex max-w-3xl flex-col space-y-16">
{articles.map((article) => (
<Article key={article.slug} article={article}/>
))}
</div>
</div>
</SimpleLayout>
</>
)
}
export async function getStaticProps() {
return {
props: {
articles: (await getAllArticles()).map(({component, ...meta}) => meta),
},
}
}

47
src/styles/prism.css Normal file
View File

@ -0,0 +1,47 @@
pre[class*='language-'] {
color: theme('colors.zinc.100');
}
.token.tag,
.token.class-name,
.token.selector,
.token.selector .class,
.token.selector.class,
.token.function {
color: theme('colors.pink.400');
}
.token.attr-name,
.token.keyword,
.token.rule,
.token.pseudo-class,
.token.important {
color: theme('colors.zinc.300');
}
.token.module {
color: theme('colors.pink.400');
}
.token.attr-value,
.token.class,
.token.string,
.token.property {
color: theme('colors.teal.300');
}
.token.punctuation,
.token.attr-equals {
color: theme('colors.zinc.500');
}
.token.unit,
.language-css .token.function {
color: theme('colors.sky.200');
}
.token.comment,
.token.operator,
.token.combinator {
color: theme('colors.zinc.400');
}

4
src/styles/tailwind.css Normal file
View File

@ -0,0 +1,4 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import './prism.css';
@import 'tailwindcss/utilities';

View File

@ -1,99 +0,0 @@
@import "mixins.module";
@import "utilities.module";
.container {
padding: 0 2rem;
}
.main {
min-height: 100vh;
padding: 4rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.links {
margin-top: 1rem;
}
.title {
margin: 0;
font-size: 2.5rem;
line-height: 1.125;
@include sm {
font-size: 3rem;
line-height: 1.15;
}
}
.title span {
display: block;
margin-top: 0.5rem;
font-size: 1.5rem;
font-weight: normal;
color: grey;
}
.bio {
font-size: 1rem;
line-height: 1.625;
max-width: 24rem;
@include lg {
max-width: 100%;
}
}
.description {
margin: 2rem 0 1rem 0;
font-weight: normal;
font-size: 1.5rem;
line-height: 1.5;
}
.title, .description, .bio {
text-align: center;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 64rem;
}
.card {
margin: 1rem;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 2px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
max-width: 24rem;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h3 {
margin: 0 0 1rem 0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.125rem;
}
.card p {
margin: 0;
font-size: 1rem;
line-height: 1.625;
}

View File

@ -1,15 +0,0 @@
html,
body {
padding: 0;
margin: 0;
font-family: "Inter", sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}

View File

@ -1,25 +0,0 @@
@import "variables.module";
@mixin sm {
@media (min-width: #{$screen-sm-min}) {
@content;
}
}
@mixin md {
@media (min-width: #{$screen-md-min}) {
@content;
}
}
@mixin lg {
@media (min-width: #{$screen-lg-min}) {
@content;
}
}
@mixin xl {
@media (min-width: #{$screen-xl-min}) {
@content;
}
}

View File

@ -1,11 +0,0 @@
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

View File

@ -1,4 +0,0 @@
$screen-sm-min: 640px;
$screen-md-min: 768px;
$screen-lg-min: 1024px;
$screen-xl-min: 1280px;

313
tailwind.config.js Normal file
View File

@ -0,0 +1,313 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx}'],
darkMode: 'class',
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/line-clamp')
],
theme: {
extend: {
colors: {
green: {
950: '#1fdf64'
}
}
},
fontSize: {
xs: ['0.8125rem', {lineHeight: '1.5rem'}],
sm: ['0.875rem', {lineHeight: '1.5rem'}],
base: ['1rem', {lineHeight: '1.75rem'}],
lg: ['1.125rem', {lineHeight: '1.75rem'}],
xl: ['1.25rem', {lineHeight: '2rem'}],
'2xl': ['1.5rem', {lineHeight: '2rem'}],
'3xl': ['1.875rem', {lineHeight: '2.25rem'}],
'4xl': ['2rem', {lineHeight: '2.5rem'}],
'5xl': ['3rem', {lineHeight: '3.5rem'}],
'6xl': ['3.75rem', {lineHeight: '1'}],
'7xl': ['4.5rem', {lineHeight: '1'}],
'8xl': ['6rem', {lineHeight: '1'}],
'9xl': ['8rem', {lineHeight: '1'}],
},
typography: (theme) => ({
invert: {
css: {
'--tw-prose-body': 'var(--tw-prose-invert-body)',
'--tw-prose-headings': 'var(--tw-prose-invert-headings)',
'--tw-prose-links': 'var(--tw-prose-invert-links)',
'--tw-prose-links-hover': 'var(--tw-prose-invert-links-hover)',
'--tw-prose-underline': 'var(--tw-prose-invert-underline)',
'--tw-prose-underline-hover':
'var(--tw-prose-invert-underline-hover)',
'--tw-prose-bold': 'var(--tw-prose-invert-bold)',
'--tw-prose-counters': 'var(--tw-prose-invert-counters)',
'--tw-prose-bullets': 'var(--tw-prose-invert-bullets)',
'--tw-prose-hr': 'var(--tw-prose-invert-hr)',
'--tw-prose-quote-borders': 'var(--tw-prose-invert-quote-borders)',
'--tw-prose-captions': 'var(--tw-prose-invert-captions)',
'--tw-prose-code': 'var(--tw-prose-invert-code)',
'--tw-prose-code-bg': 'var(--tw-prose-invert-code-bg)',
'--tw-prose-pre-code': 'var(--tw-prose-invert-pre-code)',
'--tw-prose-pre-bg': 'var(--tw-prose-invert-pre-bg)',
'--tw-prose-pre-border': 'var(--tw-prose-invert-pre-border)',
'--tw-prose-th-borders': 'var(--tw-prose-invert-th-borders)',
'--tw-prose-td-borders': 'var(--tw-prose-invert-td-borders)',
},
},
DEFAULT: {
css: {
'--tw-prose-body': theme('colors.zinc.600'),
'--tw-prose-headings': theme('colors.zinc.900'),
'--tw-prose-links': theme('colors.indigo.500'),
'--tw-prose-links-hover': theme('colors.indigo.600'),
'--tw-prose-underline': theme('colors.teal.500 / 0.2'),
'--tw-prose-underline-hover': theme('colors.indigo.500'),
'--tw-prose-bold': theme('colors.zinc.900'),
'--tw-prose-counters': theme('colors.zinc.900'),
'--tw-prose-bullets': theme('colors.zinc.900'),
'--tw-prose-hr': theme('colors.zinc.100'),
'--tw-prose-quote-borders': theme('colors.zinc.200'),
'--tw-prose-captions': theme('colors.zinc.400'),
'--tw-prose-code': theme('colors.zinc.700'),
'--tw-prose-code-bg': theme('colors.zinc.300 / 0.2'),
'--tw-prose-pre-code': theme('colors.zinc.100'),
'--tw-prose-pre-bg': theme('colors.zinc.900'),
'--tw-prose-pre-border': 'transparent',
'--tw-prose-th-borders': theme('colors.zinc.200'),
'--tw-prose-td-borders': theme('colors.zinc.100'),
'--tw-prose-invert-body': theme('colors.zinc.400'),
'--tw-prose-invert-headings': theme('colors.zinc.200'),
'--tw-prose-invert-links': theme('colors.indigo.400'),
'--tw-prose-invert-links-hover': theme('colors.indigo.400'),
'--tw-prose-invert-underline': theme('colors.indigo.400 / 0.3'),
'--tw-prose-invert-underline-hover': theme('colors.indigo.400'),
'--tw-prose-invert-bold': theme('colors.zinc.200'),
'--tw-prose-invert-counters': theme('colors.zinc.200'),
'--tw-prose-invert-bullets': theme('colors.zinc.200'),
'--tw-prose-invert-hr': theme('colors.zinc.700 / 0.4'),
'--tw-prose-invert-quote-borders': theme('colors.zinc.500'),
'--tw-prose-invert-captions': theme('colors.zinc.500'),
'--tw-prose-invert-code': theme('colors.zinc.300'),
'--tw-prose-invert-code-bg': theme('colors.zinc.200 / 0.05'),
'--tw-prose-invert-pre-code': theme('colors.zinc.100'),
'--tw-prose-invert-pre-bg': 'rgb(0 0 0 / 0.4)',
'--tw-prose-invert-pre-border': theme('colors.zinc.200 / 0.1'),
'--tw-prose-invert-th-borders': theme('colors.zinc.700'),
'--tw-prose-invert-td-borders': theme('colors.zinc.800'),
// Base
color: 'var(--tw-prose-body)',
lineHeight: theme('lineHeight.7'),
'> *': {
marginTop: theme('spacing.10'),
marginBottom: theme('spacing.10'),
},
p: {
marginTop: theme('spacing.7'),
marginBottom: theme('spacing.7'),
},
// Headings
'h2, h3': {
color: 'var(--tw-prose-headings)',
fontWeight: theme('fontWeight.semibold'),
},
h2: {
fontSize: theme('fontSize.xl')[0],
lineHeight: theme('lineHeight.7'),
marginTop: theme('spacing.20'),
marginBottom: theme('spacing.4'),
},
h3: {
fontSize: theme('fontSize.base')[0],
lineHeight: theme('lineHeight.7'),
marginTop: theme('spacing.16'),
marginBottom: theme('spacing.4'),
},
':is(h2, h3) + *': {
marginTop: 0,
},
// Images
img: {
borderRadius: theme('borderRadius.3xl'),
},
// Inline elements
a: {
color: 'var(--tw-prose-links)',
fontWeight: theme('fontWeight.semibold'),
textDecoration: 'underline',
textDecorationColor: 'var(--tw-prose-underline)',
transitionProperty: 'color, text-decoration-color',
transitionDuration: theme('transitionDuration.150'),
transitionTimingFunction: theme('transitionTimingFunction.in-out'),
},
'a:hover': {
color: 'var(--tw-prose-links-hover)',
textDecorationColor: 'var(--tw-prose-underline-hover)',
},
strong: {
color: 'var(--tw-prose-bold)',
fontWeight: theme('fontWeight.semibold'),
},
code: {
display: 'inline-block',
color: 'var(--tw-prose-code)',
fontSize: theme('fontSize.sm')[0],
fontWeight: theme('fontWeight.semibold'),
backgroundColor: 'var(--tw-prose-code-bg)',
borderRadius: theme('borderRadius.lg'),
paddingLeft: theme('spacing.1'),
paddingRight: theme('spacing.1'),
},
'a code': {
color: 'inherit',
},
':is(h2, h3) code': {
fontWeight: theme('fontWeight.bold'),
},
// Quotes
blockquote: {
paddingLeft: theme('spacing.6'),
borderLeftWidth: theme('borderWidth.2'),
borderLeftColor: 'var(--tw-prose-quote-borders)',
fontStyle: 'italic',
},
// Figures
figcaption: {
color: 'var(--tw-prose-captions)',
fontSize: theme('fontSize.sm')[0],
lineHeight: theme('lineHeight.6'),
marginTop: theme('spacing.3'),
},
'figcaption > p': {
margin: 0,
},
// Lists
ul: {
listStyleType: 'disc',
},
ol: {
listStyleType: 'decimal',
},
'ul, ol': {
paddingLeft: theme('spacing.6'),
},
li: {
marginTop: theme('spacing.6'),
marginBottom: theme('spacing.6'),
paddingLeft: theme('spacing[3.5]'),
},
'li::marker': {
fontSize: theme('fontSize.sm')[0],
fontWeight: theme('fontWeight.semibold'),
},
'ol > li::marker': {
color: 'var(--tw-prose-counters)',
},
'ul > li::marker': {
color: 'var(--tw-prose-bullets)',
},
'li :is(ol, ul)': {
marginTop: theme('spacing.4'),
marginBottom: theme('spacing.4'),
},
'li :is(li, p)': {
marginTop: theme('spacing.3'),
marginBottom: theme('spacing.3'),
},
// Code blocks
pre: {
color: 'var(--tw-prose-pre-code)',
fontSize: theme('fontSize.sm')[0],
fontWeight: theme('fontWeight.medium'),
backgroundColor: 'var(--tw-prose-pre-bg)',
borderRadius: theme('borderRadius.3xl'),
padding: theme('spacing.8'),
overflowX: 'auto',
border: '1px solid',
borderColor: 'var(--tw-prose-pre-border)',
},
'pre code': {
display: 'inline',
color: 'inherit',
fontSize: 'inherit',
fontWeight: 'inherit',
backgroundColor: 'transparent',
borderRadius: 0,
padding: 0,
},
// Horizontal rules
hr: {
marginTop: theme('spacing.20'),
marginBottom: theme('spacing.20'),
borderTopWidth: '1px',
borderColor: 'var(--tw-prose-hr)',
'@screen lg': {
marginLeft: `calc(${theme('spacing.12')} * -1)`,
marginRight: `calc(${theme('spacing.12')} * -1)`,
},
},
// Tables
table: {
width: '100%',
tableLayout: 'auto',
textAlign: 'left',
fontSize: theme('fontSize.sm')[0],
},
thead: {
borderBottomWidth: '1px',
borderBottomColor: 'var(--tw-prose-th-borders)',
},
'thead th': {
color: 'var(--tw-prose-headings)',
fontWeight: theme('fontWeight.semibold'),
verticalAlign: 'bottom',
paddingBottom: theme('spacing.2'),
},
'thead th:not(:first-child)': {
paddingLeft: theme('spacing.2'),
},
'thead th:not(:last-child)': {
paddingRight: theme('spacing.2'),
},
'tbody tr': {
borderBottomWidth: '1px',
borderBottomColor: 'var(--tw-prose-td-borders)',
},
'tbody tr:last-child': {
borderBottomWidth: 0,
},
'tbody td': {
verticalAlign: 'baseline',
},
tfoot: {
borderTopWidth: '1px',
borderTopColor: 'var(--tw-prose-th-borders)',
},
'tfoot td': {
verticalAlign: 'top',
},
':is(tbody, tfoot) td': {
paddingTop: theme('spacing.2'),
paddingBottom: theme('spacing.2'),
},
':is(tbody, tfoot) td:not(:first-child)': {
paddingLeft: theme('spacing.2'),
},
':is(tbody, tfoot) td:not(:last-child)': {
paddingRight: theme('spacing.2'),
},
},
},
}),
},
}