Migrated to app router

This commit is contained in:
Ryan Freeman 2023-07-29 23:40:36 +01:00
parent 3a81671257
commit 158973d3b6
59 changed files with 1240 additions and 1584 deletions

134
app/about/page.tsx Normal file
View File

@ -0,0 +1,134 @@
import React, {ElementType, ReactNode} from 'react'
import Link from 'next/link'
import Image from 'next/image'
import {Container} from '@/components/common/Container'
import {MailIcon} from '@/components/icons/MailIcon'
import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
import clsx from 'clsx'
import me from '@/public/images/me.jpg'
import awsCCPBadge from '@/public/images/aws-certified-cloud-practitioner-badge.png'
export const metadata = {
title: 'About - Ryan Freeman',
description: 'Im Ryan. I live in Dublin, Ireland where I work as a software engineer.'
}
function SocialLink({
className,
href,
children,
icon: Icon
}:
{
className: string,
href: string,
children: ReactNode,
icon: ElementType
}) {
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>
)
}
export default async function About() {
return (
<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 lg:max-w-sm px-2.5">
<Image
src={me}
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 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 responsive user interfaces,
and I have a deep understanding of Java for building robust 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="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>
<Link href="https://credly.com/badges/10bd0eae-b383-411c-beb9-dadda80124c8/public_url">
<Image
src={awsCCPBadge}
width="170"
height="170"
alt="AWS Certified Cloud Practitioner"
className="mt-8"
/>
</Link>
</div>
</div>
</Container>
)
}

View File

@ -0,0 +1,26 @@
import {NextResponse} from 'next/server'
import {getRamUsage, getRootFsUsage, getSysLoad, getTemp, getUptime} from '@/lib/grafana'
export const fetchCache = 'force-no-store'
export async function GET(request: Request, {params}: { params: { slug: string } }) {
const slug = params.slug
let response
if (slug === 'ram') {
response = await getRamUsage()
} else if (slug === 'rootfs') {
response = await getRootFsUsage()
} else if (slug === 'sysload') {
response = await getSysLoad()
} else if (slug === 'temp') {
response = await getTemp()
} else if (slug === 'uptime') {
response = await getUptime()
} else {
return new Response('Not Found', {
status: 404
})
}
return NextResponse.json(response)
}

View File

@ -1,5 +1,5 @@
import {NextResponse} from 'next/server'
import {getCurrentlyPlaying} from '@/lib/spotify' import {getCurrentlyPlaying} from '@/lib/spotify'
import {NextApiRequest, NextApiResponse} from 'next'
type Song = { type Song = {
item: { item: {
@ -24,24 +24,18 @@ type Song = {
is_playing: boolean is_playing: boolean
} }
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export async function GET(request: Request) {
const response = await getCurrentlyPlaying() const response = await getCurrentlyPlaying()
if (response.status === 204 || response.status > 400) { if (response.status === 204 || response.status > 400) {
return res.status(200).json({ return new Response(JSON.stringify({isPlaying: false}), {
isPlaying: false status: 200
}) })
} }
const song = await response.json() as Song const song = await response.json() as Song
const {item} = song const {item} = song
if (item === null) {
return res.status(200).json({
isPlaying: false
})
}
const artist = item.artists.map(artist => artist.name).join(', ') const artist = item.artists.map(artist => artist.name).join(', ')
const title = item.name; const title = item.name;
const songUrl = item.external_urls.spotify const songUrl = item.external_urls.spotify
@ -49,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const albumImageUrl = item.album.images[0].url const albumImageUrl = item.album.images[0].url
const isPlaying = song.is_playing; const isPlaying = song.is_playing;
return res.status(200).json({ return NextResponse.json({
artist, artist,
title, title,
songUrl, songUrl,

View File

@ -1,5 +1,5 @@
import {NextResponse} from 'next/server'
import {getRecentlyPlayed} from '@/lib/spotify' import {getRecentlyPlayed} from '@/lib/spotify'
import {NextApiRequest, NextApiResponse} from 'next'
type Tracks = { type Tracks = {
items: [ items: [
@ -28,19 +28,16 @@ type Tracks = {
] ]
} }
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export async function GET(request: Request) {
const response = await getRecentlyPlayed() const response = await getRecentlyPlayed()
if (response.status > 400) { if (response.status > 400) {
return res.status(200).json({}) return new Response(JSON.stringify({status: response.statusText}), {
status: response.status
})
} }
const tracks = await response.json() as Tracks const tracks = await response.json() as Tracks
if (tracks === null) {
return res.status(200).json({})
}
const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a) const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a)
const title = track.name; const title = track.name;
@ -49,7 +46,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const album = track.album.name const album = track.album.name
const albumImageUrl = track.album.images[0].url const albumImageUrl = track.album.images[0].url
return res.status(200).json({ return NextResponse.json({
artist, artist,
title, title,
songUrl, songUrl,

View File

@ -0,0 +1,41 @@
import {NextResponse} from 'next/server'
import {cookies} from 'next/headers'
import {createServerComponentClient} from '@supabase/auth-helpers-nextjs'
import type {Database} from '@/types/database.types'
export async function GET(request: Request, {params}: { params: { slug: string } }) {
if (typeof params.slug !== 'undefined') {
try {
const supabase = createServerComponentClient<Database>({cookies})
const slug = params.slug.toString()
const response = await supabase
.from('analytics')
.select('views')
.eq('slug', slug)
.returns<any>()
const {views} = response.data[0]
if (typeof views !== 'undefined') {
return NextResponse.json({views})
}
} catch (e) {
return new Response(JSON.stringify({status: 'Internal Server Error'}), {status: 500})
}
}
return new Response(JSON.stringify({status: 'Not Found'}), {status: 404})
}
export async function POST(request: Request, {params}: { params: { slug: string } }) {
if (typeof params.slug !== 'undefined') {
try {
const supabase = createServerComponentClient<Database>({cookies})
const slug = params.slug.toString()
// @ts-ignore
await supabase.rpc('increment_views', {page_slug: slug})
return NextResponse.json({})
} catch (e) {
return new Response(JSON.stringify({status: 'Internal Server Error'}), {status: 500})
}
}
return new Response(JSON.stringify({status: 'Not Found'}), {status: 404})
}

12
app/dashboard/loading.tsx Normal file
View File

@ -0,0 +1,12 @@
import {metadata} from './page'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
export default function LoadingSkeleton() {
return (
<SimpleLayout heading="Dashboard."
description={metadata.description}
gradient="bg-gradient-to-r from-orange-300 to-rose-300">
</SimpleLayout>
)
}

88
app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,88 @@
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {Card} from '@/components/ui/Card'
import {CardGroup} from '@/components/ui/CardGroup'
import {getDashboardData} from '@/lib/dashboard'
import {numberFormat} from '@/lib/numberFormat'
export const metadata = {
title: 'Dashboard - Ryan Freeman',
description: 'This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus.'
}
const config = {
refreshInterval: 30000
}
export const dynamic = 'force-dynamic'
export default async function Dashboard() {
const metrics = await getDashboardData()
// const tempData = (useSWR('api/grafana/temp', fetcher, config)).data as { temp: string }
// const sysLoadData = (useSWR('api/grafana/sysload', fetcher, config)).data as { sysLoad: string }
// const ramData = (useSWR('api/grafana/ram', fetcher, config)).data as { ramUsage: string }
// const rootFsData = (useSWR('api/grafana/rootfs', fetcher, config)).data as { rootFsUsage: string }
// const uptimeData = (useSWR('api/grafana/uptime', fetcher, config)).data as { days: number }
return (
<SimpleLayout heading="Dashboard."
description={metadata.description}
gradient="bg-gradient-to-r from-orange-300 to-rose-300">
{metrics.map(({groupName, groupItems}) => (
<CardGroup title={groupName} key={groupName}>
{groupItems.map((item) => (
<Card as="li" key={item.title}>
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
<Card.Link href={item.href}>{item.title}</Card.Link>
</h2>
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
{typeof item.value === "number" ? numberFormat(item.value) : item.value}
</Card.Description>
</Card>
))}
</CardGroup>
))}
{/*<CardGroup title="Raspberry Pi">*/}
{/* <Card as="li">*/}
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
{/* <Card.Link href="#">Temperature</Card.Link>*/}
{/* </h2>*/}
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
{/* {tempData ? `${tempData.temp}℃` : "—"}*/}
{/* </Card.Description>*/}
{/* </Card>*/}
{/* <Card as="li">*/}
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
{/* <Card.Link href="#">Sys load (5m avg)</Card.Link>*/}
{/* </h2>*/}
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
{/* {sysLoadData ? `${sysLoadData.sysLoad}%` : "—"}*/}
{/* </Card.Description>*/}
{/* </Card>*/}
{/* <Card as="li">*/}
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
{/* <Card.Link href="#">RAM usage</Card.Link>*/}
{/* </h2>*/}
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
{/* {ramData ? `${ramData.ramUsage}%` : "—"}*/}
{/* </Card.Description>*/}
{/* </Card>*/}
{/* <Card as="li">*/}
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
{/* <Card.Link href="#">Root FS usage</Card.Link>*/}
{/* </h2>*/}
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
{/* {rootFsData ? `${rootFsData.rootFsUsage}%` : "—"}*/}
{/* </Card.Description>*/}
{/* </Card>*/}
{/* <Card as="li">*/}
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
{/* <Card.Link href="#">Uptime days</Card.Link>*/}
{/* </h2>*/}
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
{/* {uptimeData ? `${numberFormat(uptimeData.days)}` : "—"}*/}
{/* </Card.Description>*/}
{/* </Card>*/}
{/*</CardGroup>*/}
</SimpleLayout>
)
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

34
app/layout.tsx Normal file
View File

@ -0,0 +1,34 @@
import {ReactNode} from 'react'
import {Providers} from '@/app/providers'
import {Header} from '@/components/common/Header'
import {Footer} from '@/components/common/Footer'
import '@/styles/tailwind.css'
export const metadata = {
title: 'Ryan Freeman - Full-stack software engineer from Dublin, Ireland.',
description: 'Full-stack software engineer who enjoys building cloud-native applications.'
}
export default function RootLayout({children}: { children: ReactNode }) {
return (
<html className="h-full antialiased" lang="en" suppressHydrationWarning={true}>
<body className="flex h-full flex-col dark:bg-black-950">
<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-950 dark:ring-zinc-300/20"/>
</div>
</div>
<div className="relative">
<Providers>
<Header/>
<main>
{children}
</main>
<Footer/>
</Providers>
</div>
</body>
</html>
)
}

View File

@ -1,20 +1,13 @@
import React from 'react' import React from 'react'
import Head from 'next/head'
import {GetStaticProps} from 'next'
import {Card} from '@/components/ui/Card' import {Card} from '@/components/ui/Card'
import {Resume} from '@/components/ui/Resume'
import {Container} from '@/components/common/Container'
import {
GitHubIcon,
LinkedInIcon
} from '@/components/icons/SocialIcons'
import {SocialLink} from '@/components/ui/SocialLink'
import {Views} from '@/components/ui/Views' import {Views} from '@/components/ui/Views'
import {formatDate} from '@/lib/formatDate' import {Resume} from '@/components/ui/Resume'
import {generateRssFeed} from '@/lib/generateRssFeed' import {SocialLink} from '@/components/ui/SocialLink'
import {generateSitemap} from '@/lib/generateSitemap' import {Container} from '@/components/common/Container'
import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
import {getAllArticles} from '@/lib/getAllArticles' import {getAllArticles} from '@/lib/getAllArticles'
import {Article} from 'types' import {formatDate} from '@/lib/formatDate'
import type {Article} from '@/types'
function Article(article: Article) { function Article(article: Article) {
return ( return (
@ -38,58 +31,13 @@ function Article(article: Article) {
) )
} }
export default function Home({articles}: { articles: Article[] }) { export default async function Home() {
const articles = (await getAllArticles())
.slice(0, 3)
.map(component => component)
return ( 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-og.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-og.jpg"
/>
</Head>
<Container className="mt-9"> <Container className="mt-9">
<div className="max-w-2xl"> <div className="max-w-2xl">
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500"> <h1 className="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500">
@ -135,19 +83,4 @@ export default function Home({articles}: { articles: Article[] }) {
</Container> </Container>
</> </>
) )
} }
export const getStaticProps: GetStaticProps = async () => {
if (process.env.NODE_ENV === 'production') {
await generateRssFeed()
await generateSitemap()
}
return {
props: {
articles: (await getAllArticles())
.slice(0, 3)
.map(({component, ...meta}) => meta),
}
}
}

57
app/projects/page.tsx Normal file
View File

@ -0,0 +1,57 @@
import {ShareIcon} from '@/components/icons/ShareIcon'
import {SparklesIcon} from '@/components/icons/SparklesIcon'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {Card} from '@/components/ui/Card'
import {getPinnedRepos} from '@/lib/github'
import {numberFormat} from '@/lib/numberFormat'
export const metadata = {
title: 'Projects - Ryan Freeman',
description: '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.'
}
export default async function Projects() {
const pinnedRepos = (await getPinnedRepos()).sort((a, b) => b.stargazerCount - a.stargazerCount)
return (
<SimpleLayout
heading="Things I've made and projects I've worked on."
description={metadata.description}
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"
>
{pinnedRepos.map((repo) => (
<Card as="li" key={repo.name}>
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-100">
<Card.Link href={repo.url}>{repo.name}</Card.Link>
</h2>
<Card.Description>{repo.description}</Card.Description>
<div
className="z-10 flex space-x-16 sm:space-x-0 sm:justify-between mt-8 items-center w-full group text-sm text-zinc-500 dark:text-zinc-400">
<p
className="flex items-center">
<span>{repo.primaryLanguage.name}</span>
<span
className="mr-2 w-4 h-4 rounded-full order-first"
style={{backgroundColor: repo.primaryLanguage.color}}/>
</p>
<div className="flex space-x-6">
<p className="flex items-center">
{numberFormat(repo.stargazerCount)}
<SparklesIcon className="order-first mr-2 w-5 h-5 fill-zinc-400 dark:fill-zinc-500"/>
</p>
<p className="flex items-center">
{numberFormat(repo.forkCount)}
<ShareIcon
className="order-first mr-2 w-5 h-5 fill-zinc-400 dark:fill-zinc-500 -rotate-90"/>
</p>
</div>
</div>
</Card>
))}
</ul>
</SimpleLayout>
)
}

15
app/providers.tsx Normal file
View File

@ -0,0 +1,15 @@
'use client'
import {ReactNode} from 'react'
import {ThemeProvider} from 'next-themes'
export function Providers({children}: {
children: ReactNode
}) {
return (
<ThemeProvider attribute="class">
{children}
</ThemeProvider>
)
}

18
app/sitemap.ts Normal file
View File

@ -0,0 +1,18 @@
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://acme.com',
lastModified: new Date(),
},
{
url: 'https://acme.com/about',
lastModified: new Date(),
},
{
url: 'https://acme.com/blog',
lastModified: new Date(),
},
]
}

107
app/uses/page.tsx Normal file
View File

@ -0,0 +1,107 @@
import {ReactNode} from 'react'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {Card} from '@/components/ui/Card'
import {Section} from '@/components/ui/Section'
export const metadata = {
title: 'Uses - Ryan Freeman',
description: 'Software I use, equipment that makes my job easier, and other things I recommend.'
}
function ToolsSection({children, title}: { children: ReactNode, title: string }) {
return (
<Section title={title}>
<ul role="list" className="space-y-16">
{children}
</ul>
</Section>
)
}
function Tool({title, href, children}: { title: string, href: string, children: ReactNode }) {
return (
<Card as="li">
<Card.Title as="h3" href={href}>
{title}
</Card.Title>
<Card.Description>{children}</Card.Description>
</Card>
)
}
export default function Uses() {
return (
<SimpleLayout
heading="Software I use, equipment that makes my job easier, and other things I recommend."
description="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. I&apos;ve recently added more storage and
a new monitor. 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="Productivity">
<Tool title="RegionToShare" href="https://github.com/tom-englert/RegionToShare">
Great app for Windows which allows you share a region of the screen, handy for single monitor
set ups such as ultrawides when you don&apos;t want to share the entire screen.
</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

@ -1,19 +1,18 @@
import {ArticleLayout} from '@/components/layouts/ArticleLayout' import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
import {createSlug} from '@/lib/createSlug' import {createSlug} from '../../../lib/createSlug'
export const meta = { export const metadata = {
author: 'Ryan Freeman', authors: 'Ryan Freeman',
date: '2022-12-04',
title: 'A personal journey in software engineering', 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.', date: '2022-12-04',
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.'
} }
export default (props) => <ArticleLayout export default (props) => <ArticleLayout
author={meta.author} title={metadata.title}
date={meta.date} date={metadata.date}
title={meta.title} description={metadata.description}
description={meta.description} slug={createSlug(metadata.title)}
slug={createSlug(meta.title)}
{...props} /> {...props} />
Hello there! Hello there!

View File

Before

Width:  |  Height:  |  Size: 567 KiB

After

Width:  |  Height:  |  Size: 567 KiB

View File

@ -1,23 +1,23 @@
import Image from 'next/image' import Image from 'next/image'
import {ArticleLayout} from '@/components/layouts/ArticleLayout' import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
import {createSlug} from '@/lib/createSlug' import {createSlug} from '../../../lib/createSlug'
import cargoShipImage from './ian-taylor-jOqJbvo1P9g-unsplash.jpg' import cargoShipImage from './ian-taylor-jOqJbvo1P9g-unsplash.jpg'
export const meta = { export const metadata = {
author: 'Ryan Freeman', author: 'Ryan Freeman',
date: '2023-02-11', date: '2023-02-11',
title: 'Docker cheat sheet', title: 'Docker cheat sheet',
description: 'This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.', description: 'This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.',
ogImage: '/static/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg' ogImage: '/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg'
} }
export default (props) => <ArticleLayout export default (props) => <ArticleLayout
author={meta.author} author={metadata.author}
date={meta.date} date={metadata.date}
title={meta.title} title={metadata.title}
description={meta.description} description={metadata.description}
ogImage={meta.ogImage} ogImage={metadata.ogImage}
slug={createSlug(meta.title)} slug={createSlug(metadata.title)}
{...props} /> {...props} />
This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers. This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.

View File

@ -1,7 +1,7 @@
import {ArticleLayout} from '@/components/layouts/ArticleLayout' import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
import {createSlug} from '@/lib/createSlug' import {createSlug} from '../../../lib/createSlug'
export const meta = { export const metadata = {
author: 'Ryan Freeman', author: 'Ryan Freeman',
date: '2023-01-02', date: '2023-01-02',
title: 'How to add TypeScript to an existing Next.js project', title: 'How to add TypeScript to an existing Next.js project',
@ -9,11 +9,11 @@ export const meta = {
} }
export default (props) => <ArticleLayout export default (props) => <ArticleLayout
author={meta.author} author={metadata.author}
date={meta.date} date={metadata.date}
title={meta.title} title={metadata.title}
description={meta.description} description={metadata.description}
slug={createSlug(meta.title)} slug={createSlug(metadata.title)}
{...props} /> {...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`.

54
app/writing/page.tsx Normal file
View File

@ -0,0 +1,54 @@
import React from 'react'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {Card} from '@/components/ui/Card'
import {Views} from '@/components/ui/Views'
import {formatDate} from '@/lib/formatDate'
import {getAllArticles} from '@/lib/getAllArticles'
import type {Article} from '@/types'
export const metadata = {
title: 'Writing - Ryan Freeman',
description: 'All of my long-form thoughts on software engineering, and more, displayed in chronological order.'
}
function Article({article}: { article: Article }) {
return (
<article>
<Card variant="inline">
<Card.Title href={`/writing/${article.slug}`}>
{article.title}
</Card.Title>
<p className="flex order-first space-x-1 z-10 mb-3 md:mb-0 md:ml-4 md:order-last flex-shrink-0">
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
{formatDate(article.date)}
</Card.Eyebrow>
<Views
slug={article.slug}
className="text-sm text-zinc-500 dark:text-zinc-400"
shouldUpdateViews={false}
/>
</p>
</Card>
</article>
)
}
export default async function Writing() {
const articles = (await getAllArticles()).map(({component, ...meta}) => meta)
return (
<SimpleLayout
heading="Writing on software engineering, and everything in between."
description={metadata.description}
gradient="bg-gradient-to-r from-pink-500 to-violet-500"
>
<div className="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none">
<div className="max-w-3xl space-y-16 mt-6">
{articles.map((article) => (
<Article key={article.slug} article={article}/>
))}
</div>
</div>
</SimpleLayout>
)
}

View File

@ -1,47 +1,11 @@
import {useRouter} from 'next/router' 'use client'
import {useEffect, useRef} from 'react' import {useEffect, useRef} from 'react'
import {usePathname} from 'next/navigation'
import {Container} from './Container' import {Container} from './Container'
import {MobileNavigation, DesktopNavigation} from '@/components/ui/Navigation' import {ThemeButton} from '@/components/ui/ThemeButton'
import {DesktopNavigation, MobileNavigation} from '@/components/ui/Navigation'
import {Avatar, AvatarContainer} from '@/components/ui/Avatar' import {Avatar, AvatarContainer} from '@/components/ui/Avatar'
import {MoonIcon} from '@/components/icons/MoonIcon'
import {SunIcon} from '@/components/icons/SunIcon'
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(num: number, a: number, b: number) { function clamp(num: number, a: number, b: number) {
let min = Math.min(a, b) let min = Math.min(a, b)
@ -50,8 +14,8 @@ function clamp(num: number, a: number, b: number) {
} }
export function Header() { export function Header() {
const router = useRouter() const pathname = usePathname()
const isHomePage = router.pathname === '/' const isHomePage = pathname === '/'
const headerRef = useRef<HTMLDivElement>(null) const headerRef = useRef<HTMLDivElement>(null)
const avatarRef = useRef<HTMLImageElement>(null) const avatarRef = useRef<HTMLImageElement>(null)
@ -227,7 +191,7 @@ export function Header() {
</div> </div>
<div className="flex justify-end md:flex-1"> <div className="flex justify-end md:flex-1">
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<ModeToggle/> <ThemeButton/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,4 @@
import React, {ReactNode} from 'react' import React, {ReactNode} from 'react'
import * as process from 'process'
import Head from 'next/head'
import Link from 'next/link' import Link from 'next/link'
import {Container} from '@/components/common/Container' import {Container} from '@/components/common/Container'
import {Prose} from '@/components/ui/Prose' import {Prose} from '@/components/ui/Prose'
@ -9,112 +7,66 @@ import {ArrowDownIcon} from '@/components/icons/ArrowDownIcon'
import {formatDate} from '@/lib/formatDate' import {formatDate} from '@/lib/formatDate'
type ArticleLayout = { type ArticleLayout = {
children?: ReactNode
isRssFeed: boolean
title: string title: string
description: string
ogImage: string
date: string date: string
description: string
slug: string slug: string
children?: ReactNode
ogImage?: string
isRssFeed?: boolean
} }
const gradients = [
'bg-gradient-to-r from-blue-500 to-blue-600',
'bg-[conic-gradient(at_left,_var(--tw-gradient-stops))] from-rose-500 to-indigo-700',
'bg-[conic-gradient(at_left,_var(--tw-gradient-stops))] from-sky-400 to-blue-800',
'bg-gradient-to-r from-orange-400 to-rose-400',
'bg-gradient-to-r from-sky-400 to-blue-500'
]
export function ArticleLayout({ export function ArticleLayout({
children,
isRssFeed = false,
title, title,
description,
ogImage,
date, date,
slug description,
slug,
children,
ogImage,
isRssFeed = false
}: ArticleLayout) { }: ArticleLayout) {
if (isRssFeed) { if (isRssFeed) {
return children return children
} }
return ( return (
<> <Container className="mt-16 lg:mt-32">
<Head> <div className="xl:relative">
<title>{`${title} - Ryan Freeman`}</title> <div className="mx-auto max-w-2xl">
<meta name="description" content={description}/> <Link href="/writing">
<meta <button
property="og:url" type="button"
content={`${process.env.NEXT_PUBLIC_SITE_URL}/writing/${slug}`} 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"
<meta >
property="og:type" <ArrowDownIcon
content="website" className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400 rotate-90"/>
/> </button>
<meta </Link>
property="og:title" <article>
content={title} <header className="flex flex-col">
/> <h1 className={`mt-6 text-4xl font-bold tracking-tight sm:text-5xl bg-clip-text dark:text-transparent ${gradients[Math.floor(gradients.length * Math.random())]}`}>
<meta {title}
property="og:description" </h1>
content={description} <p className="order-first text-base text-zinc-500 dark:text-zinc-400">
/> <time dateTime={date}>
{ogImage && <span>{formatDate(date)}</span>
<> </time>
<meta <Views slug={slug} shouldUpdateViews={true}/>
property="og:image" </p>
content={ogImage} </header>
/> <Prose className="mt-8">{children}</Prose>
<meta </article>
name="twitter:card"
content="summary_large_image"
/>
<meta
name="twitter:image"
content={ogImage}
/>
</>
}
<meta
property="twitter:domain"
content="ryanfreeman.dev"
/>
<meta
property="twitter:url"
content={`${process.env.NEXT_PUBLIC_SITE_URL}/writing/${slug}`}
/>
<meta
name="twitter:title"
content={title}
/>
<meta
name="twitter:description"
content={description}
/>
</Head>
<Container className="mt-16 lg:mt-32">
<div className="xl:relative">
<div className="mx-auto max-w-2xl">
<Link href="/writing" replace>
<button
type="button"
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"
>
<ArrowDownIcon
className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400 rotate-90"/>
</button>
</Link>
<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">
{title}
</h1>
<p className="order-first text-base text-zinc-500 dark:text-zinc-400">
<time dateTime={date}>
<span>{formatDate(date)}</span>
</time>
<Views slug={slug} shouldUpdateViews={true}/>
</p>
</header>
<Prose className="mt-8">{children}</Prose>
</article>
</div>
</div> </div>
</Container> </div>
</> </Container>
) )
} }

View File

@ -2,19 +2,19 @@ import {ReactNode} from 'react'
import {Container} from '@/components/common/Container' import {Container} from '@/components/common/Container'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
type SimpleLayout = { export type SimpleLayoutProps = {
title: string heading: string
intro: string description: string
children: ReactNode children: ReactNode
gradient: string gradient: string
} }
export function SimpleLayout({ export function SimpleLayout({
title, heading,
intro, description,
children, children,
gradient gradient
}: SimpleLayout) { }: SimpleLayoutProps) {
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">
@ -28,10 +28,10 @@ export function SimpleLayout({
sm:text-5xl sm:text-5xl
${gradient ? `${gradient} bg-clip-text dark:text-transparent` : ''} ${gradient ? `${gradient} bg-clip-text dark:text-transparent` : ''}
`)}> `)}>
{title} {heading}
</h1> </h1>
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400"> <p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
{intro} {description}
</p> </p>
</header> </header>
<div className="mt-16 sm:mt-20">{children}</div> <div className="mt-16 sm:mt-20">{children}</div>

View File

@ -2,7 +2,7 @@ import {Props} from '@/types'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import avatar from '@/public/static/images/avatar.jpg' import me from '/public/images/me.jpg'
export function AvatarContainer({className, ...props}: { style?: Object } & Props) { export function AvatarContainer({className, ...props}: { style?: Object } & Props) {
return ( return (
@ -25,7 +25,7 @@ export function Avatar({large = false, className, ...props}: { large?: boolean,
{...props} {...props}
> >
<Image <Image
src={avatar} src={me}
alt="" alt=""
sizes={large ? '4rem' : '2.25rem'} sizes={large ? '4rem' : '2.25rem'}
className={clsx( className={clsx(

View File

@ -1,5 +1,7 @@
'use client'
import React, {Fragment, ReactNode} from 'react' import React, {Fragment, ReactNode} from 'react'
import {useRouter} from 'next/router' import {usePathname} from 'next/navigation'
import {Popover, Transition} from '@headlessui/react' import {Popover, Transition} from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
@ -77,8 +79,8 @@ export function MobileNavigation(props: Props) {
} }
function NavItem({href, children}: { href: string } & Props) { function NavItem({href, children}: { href: string } & Props) {
const router = useRouter() const pathname = usePathname()
let isActive = router.pathname === href let isActive = pathname === href
return ( return (
<li> <li>

View File

@ -1,3 +1,5 @@
'use client'
import useSWR from 'swr' import useSWR from 'swr'
import fetcher from '@/lib/fetcher' import fetcher from '@/lib/fetcher'
import Image from 'next/image' import Image from 'next/image'

View File

@ -0,0 +1,62 @@
'use client'
import {useEffect, useState} from 'react'
import {useTheme} from 'next-themes'
import {SunIcon} from '@/components/icons/SunIcon'
import {MoonIcon} from '@/components/icons/MoonIcon'
export function ThemeButton() {
const [mounted, setMounted] = useState(false)
const {theme, setTheme} = useTheme()
useEffect(() => {
const timeout = setTimeout(() => setMounted(true), 500)
return () => clearTimeout(timeout)
}, [])
function disableTransitionsTemporarily() {
document.documentElement.classList.add('[&_*]:!transition-none')
window.setTimeout(() => {
document.documentElement.classList.remove('[&_*]:!transition-none')
}, 0)
}
function toggleTheme() {
disableTransitionsTemporarily()
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
let isSystemDarkMode = darkModeMediaQuery.matches
let isDarkMode = theme === 'dark'
if (isDarkMode === isSystemDarkMode) {
setTheme('light')
} else {
setTheme('dark')
}
}
if (!mounted) return <ThemeButton.Skeleton/>
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={() => toggleTheme()}
>
<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>
)
}
ThemeButton.Skeleton = function ThemeButtonSkeleton() {
return (
<div
className="animate-pulse rounded-full bg-zinc-100 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">
<div className="h-6 w-6"/>
</div>
)
}

View File

@ -1,4 +1,6 @@
import {useSupabaseClient} from '@supabase/auth-helpers-react' 'use client'
import {createPagesBrowserClient} from '@supabase/auth-helpers-nextjs'
import {ElementType, useEffect} from 'react' import {ElementType, useEffect} from 'react'
import useSWR, {useSWRConfig} from 'swr' import useSWR, {useSWRConfig} from 'swr'
import fetcher from '@/lib/fetcher' import fetcher from '@/lib/fetcher'
@ -12,15 +14,16 @@ type ViewsProps = {
shouldRender?: boolean shouldRender?: boolean
} }
const supabase = createPagesBrowserClient()
export function Views({as: Component = 'span', slug, className, shouldUpdateViews = true, shouldRender = true}: ViewsProps) { export function Views({as: Component = 'span', slug, className, shouldUpdateViews = true, shouldRender = true}: ViewsProps) {
const supabaseClient = useSupabaseClient()
const {data} = useSWR(`/api/views/${slug}`, fetcher) as { data: { views: number } } const {data} = useSWR(`/api/views/${slug}`, fetcher) as { data: { views: number } }
const {mutate} = useSWRConfig() const {mutate} = useSWRConfig()
useEffect(() => { useEffect(() => {
if (shouldUpdateViews) { if (shouldUpdateViews) {
// subscribe to analytics table and react to updates at row level // subscribe to analytics table and react to updates at row level
const sub = supabaseClient const sub = supabase
.channel('any') .channel('any')
.on('postgres_changes', { .on('postgres_changes', {
event: 'UPDATE', event: 'UPDATE',

View File

@ -1,20 +1,14 @@
import {GetServerSidePropsContext} from 'next' import {cookies} from 'next/headers'
import {createServerSupabaseClient} from '@supabase/auth-helpers-nextjs' import {createServerComponentClient} from '@supabase/auth-helpers-nextjs'
import { import {getTopRepo, getTotalFollowers, getTotalForks, getTotalRepos, getTotalStars} from '@/lib/github'
getTopRepo,
getTotalFollowers,
getTotalForks,
getTotalRepos,
getTotalStars
} from '@/lib/github'
import {getAllArticles} from '@/lib/getAllArticles' import {getAllArticles} from '@/lib/getAllArticles'
import {getTopArtist, getTopGenre} from '@/lib/spotify' import {getTopArtist, getTopGenre} from '@/lib/spotify'
import {getStats} from '@/lib/statsfm'
import {Metric} from '@/types' import {Metric} from '@/types'
import {getStats} from "@/lib/statsfm";
export async function getDashboardData(context: GetServerSidePropsContext) { export async function getDashboardData() {
const supabaseClient = createServerSupabaseClient(context) const supabase = createServerComponentClient({cookies})
const {data: views} = await supabaseClient.rpc('total_views') const {data: views} = await supabase.rpc('total_views')
const [totalRepos, totalFollowers] = await Promise.all([ const [totalRepos, totalFollowers] = await Promise.all([
getTotalRepos(), getTotalRepos(),
getTotalFollowers() getTotalFollowers()
@ -26,27 +20,27 @@ export async function getDashboardData(context: GetServerSidePropsContext) {
const totalArticles = (await getAllArticles()).length const totalArticles = (await getAllArticles()).length
const topArtist = await getTopArtist() const topArtist = await getTopArtist()
const {genre} = await getTopGenre() const {genre} = await getTopGenre()
// const {hoursListened, minutesListened, streams} = await getStats() const {hoursListened, minutesListened, streams} = await getStats()
const metrics: Metric[] = [ const metrics: Metric[] = [
// { {
// title: "Streams", title: "Streams",
// value: +streams, value: +streams,
// group: "Spotify", group: "Spotify",
// href: "https://open.spotify.com/?" href: "https://open.spotify.com/?"
// }, },
// { {
// title: "Hours listened", title: "Hours listened",
// value: +hoursListened, value: +hoursListened,
// group: "Spotify", group: "Spotify",
// href: "https://open.spotify.com/?" href: "https://open.spotify.com/?"
// }, },
// { {
// title: "Minutes listened", title: "Minutes listened",
// value: +minutesListened, value: +minutesListened,
// group: "Spotify", group: "Spotify",
// href: "https://open.spotify.com/?" href: "https://open.spotify.com/?"
// }, },
{ {
title: "Top genre", title: "Top genre",
value: genre, value: genre,

View File

@ -18,8 +18,8 @@ export async function generateRssFeed() {
author, author,
id: siteUrl!, id: siteUrl!,
link: siteUrl, link: siteUrl,
image: `${siteUrl}/static/icons/favicon.ico`, image: `${siteUrl}/favicon.ico`,
favicon: `${siteUrl}/static/icons/favicon.ico`, favicon: `${siteUrl}/favicon.ico`,
copyright: `All rights reserved ${new Date().getFullYear()}`, copyright: `All rights reserved ${new Date().getFullYear()}`,
feedLinks: { feedLinks: {
rss2: `${siteUrl}/rss/feed.xml`, rss2: `${siteUrl}/rss/feed.xml`,

View File

@ -2,19 +2,19 @@ import glob from 'fast-glob'
import * as path from 'path' import * as path from 'path'
async function importArticle(articleFilename: string) { async function importArticle(articleFilename: string) {
let {meta, default: component} = await import( let {metadata, default: component} = await import(
`/pages/writing/${articleFilename}` `/app/writing/${articleFilename}`
) )
return { return {
slug: articleFilename.replace(/(\/index)?\.mdx$/, ''), slug: articleFilename.replace(/(\/page)?\.mdx$/, ''),
...meta, ...metadata,
component, component,
} }
} }
export async function getAllArticles() { export async function getAllArticles() {
let articleFilenames = await glob(['*.mdx', '*/index.mdx'], { let articleFilenames = await glob(['*.mdx', '*/page.mdx'], {
cwd: path.join(process.cwd(), './pages/writing'), cwd: path.join(process.cwd(), './app/writing'),
}) })
let articles = await Promise.all(articleFilenames.map(importArticle)) let articles = await Promise.all(articleFilenames.map(importArticle))

15
mdx-components.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { MDXComponents } from 'mdx/types'
// This file allows you to provide custom React components
// to be used in MDX files. You can import and use any
// React component you want, including components from
// other libraries.
// This file is required to use MDX in `app` directory.
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// Allows customizing built-in components, e.g. to add styling.
// h1: ({ children }) => <h1 style={{ fontSize: "100px" }}>{children}</h1>,
...components,
}
}

11
middleware.ts Normal file
View File

@ -0,0 +1,11 @@
import {createMiddlewareClient} from '@supabase/auth-helpers-nextjs'
import type {NextRequest} from 'next/server'
import {NextResponse} from 'next/server'
import type {Database} from '@/types/database.types'
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const supabase = createMiddlewareClient<Database>({req, res})
await supabase.auth.getSession()
return res
}

888
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,25 +12,28 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.7", "@headlessui/react": "^1.7.7",
"@mapbox/rehype-prism": "^0.7.0", "@mapbox/rehype-prism": "^0.7.0",
"@next/mdx": "^13.1.1", "@mdx-js/loader": "^2.3.0",
"@supabase/auth-helpers-nextjs": "^0.6.0", "@mdx-js/react": "^2.3.0",
"@supabase/auth-helpers-react": "^0.3.1", "@next/mdx": "^13.4.10",
"@supabase/auth-helpers-nextjs": "^0.7.3",
"@supabase/supabase-js": "^2.27.0",
"@tailwindcss/typography": "^0.5.8", "@tailwindcss/typography": "^0.5.8",
"@types/mdx": "^2.0.3", "@types/mdx": "^2.0.5",
"@types/node": "^18.11.18", "@types/node": "^18.11.18",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/react": "18.0.26", "@types/react": "18.0.26",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.10",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"encoding": "^0.1.13",
"eslint": "8.31.0", "eslint": "8.31.0",
"eslint-config-next": "^13.3.0", "eslint-config-next": "^13.3.0",
"fast-glob": "^3.2.12", "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.5", "motion": "^10.15.5",
"next": "^13.3.0", "next": "^13.4.10",
"node-fetch": "^3.3.0", "next-themes": "^0.2.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"postcss-focus-visible": "^7.1.0", "postcss-focus-visible": "^7.1.0",
@ -38,13 +41,11 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sharp": "^0.31.3", "sharp": "^0.31.3",
"supabase": "^1.50.2",
"swr": "^2.1.2", "swr": "^2.1.2",
"tailwind-merge": "^1.9.0", "tailwind-merge": "^1.9.0",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "4.9.4" "typescript": "4.9.4"
},
"devDependencies": {
"supabase": "^1.50.2"
} }
} }

View File

@ -1,50 +0,0 @@
import type {AppProps} from 'next/app'
import {useRouter} from 'next/router'
import NProgress from 'nprogress'
import {createBrowserSupabaseClient} from '@supabase/auth-helpers-nextjs'
import {SessionContextProvider, Session} from '@supabase/auth-helpers-react'
import {useEffect, useState} from 'react'
import {Header} from '@/components/common/Header'
import {Footer} from '@/components/common/Footer'
import '../styles/nprogress.css'
import '../styles/tailwind.css'
import 'focus-visible'
export default function App({Component, pageProps}: AppProps<{ initialSession: Session }>) {
const [supabaseClient] = useState(() => createBrowserSupabaseClient())
const router = useRouter()
NProgress.configure({showSpinner: false})
useEffect(() => {
const handleRouteStart = () => NProgress.start()
const handleRouteDone = () => NProgress.done()
router.events.on("routeChangeStart", handleRouteStart)
router.events.on("routeChangeComplete", handleRouteDone)
router.events.on("routeChangeError", handleRouteDone)
return () => {
router.events.off("routeChangeStart", handleRouteStart)
router.events.off("routeChangeComplete", handleRouteDone)
router.events.off("routeChangeError", handleRouteDone)
}
})
return (
<SessionContextProvider supabaseClient={supabaseClient} initialSession={pageProps.initialSession}>
<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-950 dark:ring-zinc-300/20"/>
</div>
</div>
<div className="relative">
<Header/>
<main>
<Component {...pageProps} />
</main>
<Footer/>
</div>
</SessionContextProvider>
)
}

View File

@ -1,79 +0,0 @@
import {Html, Head, Main, NextScript} from 'next/document'
const modeScript = `
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"
title="RSS feed for ryanfreeman.dev"
/>
<link
rel="alternate"
type="application/feed+json"
href="/rss/feed.json"
title="RSS feed for ryanfreeman.dev"
/>
<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>
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
<body className="flex h-full flex-col dark:bg-black-950">
<Main/>
<NextScript/>
</body>
</Html>
);
}

View File

@ -1,146 +0,0 @@
import Image from 'next/image'
import {ElementType, ReactNode} from 'react'
import Head from 'next/head'
import Link from 'next/link'
import clsx from 'clsx'
import {Container} from '@/components/common/Container'
import {
GitHubIcon,
LinkedInIcon
} from '@/components/icons/SocialIcons'
import {MailIcon} from '@/components/icons/MailIcon'
import photoOfMe from '@/public/static/images/photo-of-me.jpg'
import awsCCPBadge from '@/public/static/images/aws-certified-cloud-practitioner-badge.png'
function SocialLink({
className,
href,
children,
icon: Icon
}:
{
className: string,
href: string,
children: ReactNode,
icon: ElementType
}) {
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>
)
}
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={photoOfMe}
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 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="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>
<Link href="https://credly.com/badges/10bd0eae-b383-411c-beb9-dadda80124c8/public_url">
<Image
src={awsCCPBadge}
width="170"
height="170"
alt="AWS Certified Cloud Practitioner"
className="mt-8"
/>
</Link>
</div>
</div>
</Container>
</>
)
}

View File

@ -1,8 +0,0 @@
import {NextApiRequest, NextApiResponse} from 'next'
import {getRamUsage} from '@/lib/grafana'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await getRamUsage()
return res.status(200).json(response)
}

View File

@ -1,8 +0,0 @@
import {NextApiRequest, NextApiResponse} from 'next'
import {getRootFsUsage} from '@/lib/grafana'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await getRootFsUsage()
return res.status(200).json(response)
}

View File

@ -1,8 +0,0 @@
import {NextApiRequest, NextApiResponse} from 'next'
import {getSysLoad} from '@/lib/grafana'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await getSysLoad()
return res.status(200).json(response)
}

View File

@ -1,8 +0,0 @@
import {NextApiRequest, NextApiResponse} from 'next'
import {getTemp} from '@/lib/grafana'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await getTemp()
return res.status(200).json(response)
}

View File

@ -1,8 +0,0 @@
import {NextApiRequest, NextApiResponse} from 'next'
import {getUptime} from '@/lib/grafana'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await getUptime()
return res.status(200).json(response)
}

View File

@ -1,39 +0,0 @@
import {createServerSupabaseClient} from '@supabase/auth-helpers-nextjs'
import {NextApiRequest, NextApiResponse} from 'next'
import {Database} from '@/types/database.types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const supabaseServerClient = createServerSupabaseClient<Database>({
req,
res,
})
if (req.method === 'POST') {
if (req.query.slug !== undefined) {
const slug: string = req.query.slug.toString()
// @ts-ignore
await supabaseServerClient.rpc('increment_views', {page_slug: slug})
return res.status(200).json({})
}
return res.status(400).json({})
} else if (req.method === 'GET') {
if (req.query.slug !== undefined) {
const slug: string = req.query.slug.toString()
const response = await supabaseServerClient
.from('analytics')
.select('views')
.eq('slug', slug)
.returns<any>()
const {views} = response.data[0]
return res.status(200).json({views})
}
return res.status(400).json({})
}
return res.status(405).json({})
}

View File

@ -1,113 +0,0 @@
import React from 'react'
import Head from 'next/head'
import {GetServerSideProps} from 'next'
import useSWR from 'swr'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {Card} from '@/components/ui/Card'
import {CardGroup} from '@/components/ui/CardGroup'
import {numberFormat} from '@/lib/numberFormat'
import {getDashboardData} from '@/lib/dashboard'
import fetcher from '@/lib/fetcher'
import type {MetricGroup} from '@/types'
const config = {
refreshInterval: 30000
}
export default function Dashboard({metrics}: { metrics: MetricGroup }) {
const tempData = (useSWR('api/grafana/temp', fetcher, config)).data as { temp: string }
const sysLoadData = (useSWR('api/grafana/sysload', fetcher, config)).data as { sysLoad: string }
const ramData = (useSWR('api/grafana/ram', fetcher, config)).data as { ramUsage: string }
const rootFsData = (useSWR('api/grafana/rootfs', fetcher, config)).data as { rootFsUsage: string }
const uptimeData = (useSWR('api/grafana/uptime', fetcher, config)).data as { days: number }
return (
<>
<Head>
<title>Dashboard - Ryan Freeman</title>
<meta
name="description"
content="This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus."
/>
<meta
property="og:title"
content="Dashboard - Ryan Freeman"
/>
<meta
property="og:description"
content="This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus."
/>
</Head>
<SimpleLayout
title="Dashboard."
intro="This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus."
gradient="bg-gradient-to-r from-orange-300 to-rose-300"
>
{metrics.map(({groupName, groupItems}) => (
<CardGroup title={groupName} key={groupName}>
{groupItems.map((item) => (
<Card as="li" key={item.title}>
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
<Card.Link href={item.href}>{item.title}</Card.Link>
</h2>
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
{typeof item.value === "number" ? numberFormat(item.value) : item.value}
</Card.Description>
</Card>
))}
</CardGroup>
))}
<CardGroup title="Raspberry Pi">
<Card as="li">
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
<Card.Link href="#">Temperature</Card.Link>
</h2>
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
{tempData ? `${tempData.temp}` : "—"}
</Card.Description>
</Card>
<Card as="li">
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
<Card.Link href="#">Sys load (5m avg)</Card.Link>
</h2>
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
{sysLoadData ? `${sysLoadData.sysLoad}%` : "—"}
</Card.Description>
</Card>
<Card as="li">
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
<Card.Link href="#">RAM usage</Card.Link>
</h2>
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
{ramData ? `${ramData.ramUsage}%` : "—"}
</Card.Description>
</Card>
<Card as="li">
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
<Card.Link href="#">Root FS usage</Card.Link>
</h2>
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
{rootFsData ? `${rootFsData.rootFsUsage}%` : "—"}
</Card.Description>
</Card>
<Card as="li">
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
<Card.Link href="#">Uptime days</Card.Link>
</h2>
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
{uptimeData ? `${numberFormat(uptimeData.days)}` : "—"}
</Card.Description>
</Card>
</CardGroup>
</SimpleLayout>
</>
)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
return {
props: {
metrics: await getDashboardData(context)
}
}
}

View File

@ -1,80 +0,0 @@
import {GetStaticProps} from 'next'
import Head from 'next/head'
import {Card} from '@/components/ui/Card'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {SparklesIcon} from '@/components/icons/SparklesIcon'
import {ShareIcon} from '@/components/icons/ShareIcon'
import {getPinnedRepos} from '@/lib/github'
import {numberFormat} from '@/lib/numberFormat'
import type {Repo} from '@/types'
export default function Projects({pinnedRepos}: { pinnedRepos: Repo[] }) {
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"
>
{pinnedRepos.map((repo) => (
<Card as="li" key={repo.name}>
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-100">
<Card.Link href={repo.url}>{repo.name}</Card.Link>
</h2>
<Card.Description>{repo.description}</Card.Description>
<div
className="z-10 flex space-x-16 sm:space-x-0 sm:justify-between mt-8 items-center w-full group text-sm text-zinc-500 dark:text-zinc-400">
<p
className="flex items-center">
<span>{repo.primaryLanguage.name}</span>
<span
className="mr-2 w-4 h-4 rounded-full order-first"
style={{backgroundColor: repo.primaryLanguage.color}}/>
</p>
<div className="flex space-x-6">
<p className="flex items-center">
{numberFormat(repo.stargazerCount)}
<SparklesIcon className="order-first mr-2 w-5 h-5 fill-zinc-400 dark:fill-zinc-500"/>
</p>
<p className="flex items-center">
{numberFormat(repo.forkCount)}
<ShareIcon
className="order-first mr-2 w-5 h-5 fill-zinc-400 dark:fill-zinc-500 -rotate-90"/>
</p>
</div>
</div>
</Card>
))}
</ul>
</SimpleLayout>
</>
)
}
export const getStaticProps: GetStaticProps = async () => {
return {
props: {
pinnedRepos: (await getPinnedRepos())
.sort((a, b) => b.stargazerCount - a.stargazerCount)
}
}
}

View File

@ -1,122 +0,0 @@
import Head from 'next/head'
import {Card} from '@/components/ui/Card'
import {Section} from '@/components/ui/Section'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {ReactNode} from 'react'
function ToolsSection({children, title}: { children: ReactNode, title: string }) {
return (
<Section title={title}>
<ul role="list" className="space-y-16">
{children}
</ul>
</Section>
)
}
function Tool({title, href, children}: { title: string, href: string, children: ReactNode }) {
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. I&apos;ve recently added more storage and
a new monitor. 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="Productivity">
<Tool title="RegionToShare" href="https://github.com/tom-englert/RegionToShare">
Great app for Windows which allows you share a region of the screen, handy for single monitor
set ups such as ultrawides when you don&apos;t want to share the entire screen.
</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

@ -1,74 +0,0 @@
import React from 'react'
import {GetStaticProps} from 'next'
import Head from 'next/head'
import {Card} from '@/components/ui/Card'
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
import {Views} from '@/components/ui/Views'
import {getAllArticles} from '@/lib/getAllArticles'
import {formatDate} from '@/lib/formatDate'
import {Article} from 'types'
function Article({article}: { article: Article }) {
return (
<article>
<Card variant="inline">
<Card.Title href={`/writing/${article.slug}`}>
{article.title}
</Card.Title>
<p className="flex order-first space-x-1 z-10 mb-3 md:mb-0 md:ml-4 md:order-last flex-shrink-0">
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
{formatDate(article.date)}
</Card.Eyebrow>
<Views
slug={article.slug}
className="text-sm text-zinc-500 dark:text-zinc-400"
shouldUpdateViews={false}
/>
</p>
</Card>
</article>
)
}
export default function ArticlesIndex({articles}: { articles: Article[] }) {
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="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none">
<div className="max-w-3xl space-y-16 mt-6">
{articles.map((article) => (
<Article key={article.slug} article={article}/>
))}
</div>
</div>
</SimpleLayout>
</>
)
}
export const getStaticProps: GetStaticProps = async () => {
return {
props: {
articles: (await getAllArticles()).map(({component, ...meta}) => meta),
}
}
}

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 239 KiB

BIN
public/images/me.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

View File

@ -1,7 +1,11 @@
import type {Config} from 'tailwindcss' import type {Config} from 'tailwindcss'
export default { export default {
content: ['./pages/**/*.{js,jsx,tsx}', './components/**/*.{js,jsx,tsx}'], content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,jsx,tsx}',
'./components/**/*.{js,jsx,tsx}'
],
darkMode: 'class', darkMode: 'class',
plugins: [ plugins: [
require('@tailwindcss/typography') require('@tailwindcss/typography')

View File

@ -23,12 +23,18 @@
"@/*": [ "@/*": [
"*" "*"
] ]
} },
"plugins": [
{
"name": "next"
}
]
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx" "**/*.tsx",
".next/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"