Migrated to app router
							
								
								
									
										134
									
								
								app/about/page.tsx
									
									
									
									
									
										Normal 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: 'I’m 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">
 | 
			
		||||
                        I’m 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'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'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'm not tinkering, I mostly spend time with my
 | 
			
		||||
                            family and enjoy travelling whenever I can get away.
 | 
			
		||||
                        </p>
 | 
			
		||||
                        <p>
 | 
			
		||||
                            That'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'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>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								app/api/rpi/[slug]/route.ts
									
									
									
									
									
										Normal 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)
 | 
			
		||||
}
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import {NextResponse} from 'next/server'
 | 
			
		||||
import {getCurrentlyPlaying} from '@/lib/spotify'
 | 
			
		||||
import {NextApiRequest, NextApiResponse} from 'next'
 | 
			
		||||
 | 
			
		||||
type Song = {
 | 
			
		||||
    item: {
 | 
			
		||||
@ -24,24 +24,18 @@ type Song = {
 | 
			
		||||
    is_playing: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
			
		||||
export async function GET(request: Request) {
 | 
			
		||||
    const response = await getCurrentlyPlaying()
 | 
			
		||||
 | 
			
		||||
    if (response.status === 204 || response.status > 400) {
 | 
			
		||||
        return res.status(200).json({
 | 
			
		||||
            isPlaying: false
 | 
			
		||||
        return new Response(JSON.stringify({isPlaying: false}), {
 | 
			
		||||
            status: 200
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const song = await response.json() as Song
 | 
			
		||||
    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
 | 
			
		||||
@ -49,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
 | 
			
		||||
    const albumImageUrl = item.album.images[0].url
 | 
			
		||||
    const isPlaying = song.is_playing;
 | 
			
		||||
 | 
			
		||||
    return res.status(200).json({
 | 
			
		||||
    return NextResponse.json({
 | 
			
		||||
        artist,
 | 
			
		||||
        title,
 | 
			
		||||
        songUrl,
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import {NextResponse} from 'next/server'
 | 
			
		||||
import {getRecentlyPlayed} from '@/lib/spotify'
 | 
			
		||||
import {NextApiRequest, NextApiResponse} from 'next'
 | 
			
		||||
 | 
			
		||||
type Tracks = {
 | 
			
		||||
    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()
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
@ -49,7 +46,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
 | 
			
		||||
    const album = track.album.name
 | 
			
		||||
    const albumImageUrl = track.album.images[0].url
 | 
			
		||||
 | 
			
		||||
    return res.status(200).json({
 | 
			
		||||
    return NextResponse.json({
 | 
			
		||||
        artist,
 | 
			
		||||
        title,
 | 
			
		||||
        songUrl,
 | 
			
		||||
							
								
								
									
										41
									
								
								app/api/views/[slug]/route.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						@ -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
									
								
							
							
						
						@ -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
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 34 KiB  | 
							
								
								
									
										34
									
								
								app/layout.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -1,20 +1,13 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import Head from 'next/head'
 | 
			
		||||
import {GetStaticProps} from 'next'
 | 
			
		||||
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 {formatDate} from '@/lib/formatDate'
 | 
			
		||||
import {generateRssFeed} from '@/lib/generateRssFeed'
 | 
			
		||||
import {generateSitemap} from '@/lib/generateSitemap'
 | 
			
		||||
import {Resume} from '@/components/ui/Resume'
 | 
			
		||||
import {SocialLink} from '@/components/ui/SocialLink'
 | 
			
		||||
import {Container} from '@/components/common/Container'
 | 
			
		||||
import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
 | 
			
		||||
import {getAllArticles} from '@/lib/getAllArticles'
 | 
			
		||||
import {Article} from 'types'
 | 
			
		||||
import {formatDate} from '@/lib/formatDate'
 | 
			
		||||
import type {Article} from '@/types'
 | 
			
		||||
 | 
			
		||||
function Article(article: Article) {
 | 
			
		||||
    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 (
 | 
			
		||||
        <>
 | 
			
		||||
            <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">
 | 
			
		||||
                <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">
 | 
			
		||||
@ -136,18 +84,3 @@ export default function Home({articles}: { articles: Article[] }) {
 | 
			
		||||
        </>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
									
								
							
							
						
						@ -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
									
								
							
							
						
						@ -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
									
								
							
							
						
						@ -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
									
								
							
							
						
						@ -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. Here’s 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'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'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'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'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'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'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'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'm using, I always have the
 | 
			
		||||
                        same set of bookmarks.
 | 
			
		||||
                    </Tool>
 | 
			
		||||
                </ToolsSection>
 | 
			
		||||
            </div>
 | 
			
		||||
        </SimpleLayout>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -1,19 +1,18 @@
 | 
			
		||||
import {ArticleLayout} from '@/components/layouts/ArticleLayout'
 | 
			
		||||
import {createSlug} from '@/lib/createSlug'
 | 
			
		||||
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
 | 
			
		||||
import {createSlug} from '../../../lib/createSlug'
 | 
			
		||||
 | 
			
		||||
export const meta = {
 | 
			
		||||
    author: 'Ryan Freeman',
 | 
			
		||||
    date: '2022-12-04',
 | 
			
		||||
export const metadata = {
 | 
			
		||||
    authors: 'Ryan Freeman',
 | 
			
		||||
    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
 | 
			
		||||
    author={meta.author}
 | 
			
		||||
    date={meta.date}
 | 
			
		||||
    title={meta.title}
 | 
			
		||||
    description={meta.description}
 | 
			
		||||
    slug={createSlug(meta.title)}
 | 
			
		||||
    title={metadata.title}
 | 
			
		||||
    date={metadata.date}
 | 
			
		||||
    description={metadata.description}
 | 
			
		||||
    slug={createSlug(metadata.title)}
 | 
			
		||||
    {...props} />
 | 
			
		||||
 | 
			
		||||
Hello there!
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 567 KiB After Width: | Height: | Size: 567 KiB  | 
@ -1,23 +1,23 @@
 | 
			
		||||
import Image from 'next/image'
 | 
			
		||||
import {ArticleLayout} from '@/components/layouts/ArticleLayout'
 | 
			
		||||
import {createSlug} from '@/lib/createSlug'
 | 
			
		||||
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
 | 
			
		||||
import {createSlug} from '../../../lib/createSlug'
 | 
			
		||||
import cargoShipImage from './ian-taylor-jOqJbvo1P9g-unsplash.jpg'
 | 
			
		||||
 | 
			
		||||
export const meta = {
 | 
			
		||||
export const metadata = {
 | 
			
		||||
    author: 'Ryan Freeman',
 | 
			
		||||
    date: '2023-02-11',
 | 
			
		||||
    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.',
 | 
			
		||||
    ogImage: '/static/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg'
 | 
			
		||||
    ogImage: '/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default (props) => <ArticleLayout
 | 
			
		||||
    author={meta.author}
 | 
			
		||||
    date={meta.date}
 | 
			
		||||
    title={meta.title}
 | 
			
		||||
    description={meta.description}
 | 
			
		||||
    ogImage={meta.ogImage}
 | 
			
		||||
    slug={createSlug(meta.title)}
 | 
			
		||||
    author={metadata.author}
 | 
			
		||||
    date={metadata.date}
 | 
			
		||||
    title={metadata.title}
 | 
			
		||||
    description={metadata.description}
 | 
			
		||||
    ogImage={metadata.ogImage}
 | 
			
		||||
    slug={createSlug(metadata.title)}
 | 
			
		||||
    {...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.
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import {ArticleLayout} from '@/components/layouts/ArticleLayout'
 | 
			
		||||
import {createSlug} from '@/lib/createSlug'
 | 
			
		||||
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
 | 
			
		||||
import {createSlug} from '../../../lib/createSlug'
 | 
			
		||||
 | 
			
		||||
export const meta = {
 | 
			
		||||
export const metadata = {
 | 
			
		||||
    author: 'Ryan Freeman',
 | 
			
		||||
    date: '2023-01-02',
 | 
			
		||||
    title: 'How to add TypeScript to an existing Next.js project',
 | 
			
		||||
@ -9,11 +9,11 @@ export const meta = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default (props) => <ArticleLayout
 | 
			
		||||
    author={meta.author}
 | 
			
		||||
    date={meta.date}
 | 
			
		||||
    title={meta.title}
 | 
			
		||||
    description={meta.description}
 | 
			
		||||
    slug={createSlug(meta.title)}
 | 
			
		||||
    author={metadata.author}
 | 
			
		||||
    date={metadata.date}
 | 
			
		||||
    title={metadata.title}
 | 
			
		||||
    description={metadata.description}
 | 
			
		||||
    slug={createSlug(metadata.title)}
 | 
			
		||||
    {...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`.
 | 
			
		||||
							
								
								
									
										54
									
								
								app/writing/page.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -1,47 +1,11 @@
 | 
			
		||||
import {useRouter} from 'next/router'
 | 
			
		||||
'use client'
 | 
			
		||||
 | 
			
		||||
import {useEffect, useRef} from 'react'
 | 
			
		||||
import {usePathname} from 'next/navigation'
 | 
			
		||||
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 {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) {
 | 
			
		||||
    let min = Math.min(a, b)
 | 
			
		||||
@ -50,8 +14,8 @@ function clamp(num: number, a: number, b: number) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Header() {
 | 
			
		||||
    const router = useRouter()
 | 
			
		||||
    const isHomePage = router.pathname === '/'
 | 
			
		||||
    const pathname = usePathname()
 | 
			
		||||
    const isHomePage = pathname === '/'
 | 
			
		||||
 | 
			
		||||
    const headerRef = useRef<HTMLDivElement>(null)
 | 
			
		||||
    const avatarRef = useRef<HTMLImageElement>(null)
 | 
			
		||||
@ -227,7 +191,7 @@ export function Header() {
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div className="flex justify-end md:flex-1">
 | 
			
		||||
                                <div className="pointer-events-auto">
 | 
			
		||||
                                    <ModeToggle/>
 | 
			
		||||
                                    <ThemeButton/>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,4 @@
 | 
			
		||||
import React, {ReactNode} from 'react'
 | 
			
		||||
import * as process from 'process'
 | 
			
		||||
import Head from 'next/head'
 | 
			
		||||
import Link from 'next/link'
 | 
			
		||||
import {Container} from '@/components/common/Container'
 | 
			
		||||
import {Prose} from '@/components/ui/Prose'
 | 
			
		||||
@ -9,112 +7,66 @@ import {ArrowDownIcon} from '@/components/icons/ArrowDownIcon'
 | 
			
		||||
import {formatDate} from '@/lib/formatDate'
 | 
			
		||||
 | 
			
		||||
type ArticleLayout = {
 | 
			
		||||
    children?: ReactNode
 | 
			
		||||
    isRssFeed: boolean
 | 
			
		||||
    title: string
 | 
			
		||||
    description: string
 | 
			
		||||
    ogImage: string
 | 
			
		||||
    date: string
 | 
			
		||||
    description: 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({
 | 
			
		||||
                                  children,
 | 
			
		||||
                                  isRssFeed = false,
 | 
			
		||||
                                  title,
 | 
			
		||||
                                  description,
 | 
			
		||||
                                  ogImage,
 | 
			
		||||
                                  date,
 | 
			
		||||
                                  slug
 | 
			
		||||
                                  description,
 | 
			
		||||
                                  slug,
 | 
			
		||||
                                  children,
 | 
			
		||||
                                  ogImage,
 | 
			
		||||
                                  isRssFeed = false
 | 
			
		||||
                              }: ArticleLayout) {
 | 
			
		||||
    if (isRssFeed) {
 | 
			
		||||
        return children
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <>
 | 
			
		||||
            <Head>
 | 
			
		||||
                <title>{`${title} - Ryan Freeman`}</title>
 | 
			
		||||
                <meta name="description" content={description}/>
 | 
			
		||||
                <meta
 | 
			
		||||
                    property="og:url"
 | 
			
		||||
                    content={`${process.env.NEXT_PUBLIC_SITE_URL}/writing/${slug}`}
 | 
			
		||||
                />
 | 
			
		||||
                <meta
 | 
			
		||||
                    property="og:type"
 | 
			
		||||
                    content="website"
 | 
			
		||||
                />
 | 
			
		||||
                <meta
 | 
			
		||||
                    property="og:title"
 | 
			
		||||
                    content={title}
 | 
			
		||||
                />
 | 
			
		||||
                <meta
 | 
			
		||||
                    property="og:description"
 | 
			
		||||
                    content={description}
 | 
			
		||||
                />
 | 
			
		||||
                {ogImage &&
 | 
			
		||||
                    <>
 | 
			
		||||
                        <meta
 | 
			
		||||
                            property="og:image"
 | 
			
		||||
                            content={ogImage}
 | 
			
		||||
                        />
 | 
			
		||||
                        <meta
 | 
			
		||||
                            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>
 | 
			
		||||
        <Container className="mt-16 lg:mt-32">
 | 
			
		||||
            <div className="xl:relative">
 | 
			
		||||
                <div className="mx-auto max-w-2xl">
 | 
			
		||||
                    <Link href="/writing">
 | 
			
		||||
                        <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 sm:text-5xl bg-clip-text dark:text-transparent ${gradients[Math.floor(gradients.length * Math.random())]}`}>
 | 
			
		||||
                                {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>
 | 
			
		||||
            </Container>
 | 
			
		||||
        </>
 | 
			
		||||
            </div>
 | 
			
		||||
        </Container>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -2,19 +2,19 @@ import {ReactNode} from 'react'
 | 
			
		||||
import {Container} from '@/components/common/Container'
 | 
			
		||||
import {twMerge} from 'tailwind-merge'
 | 
			
		||||
 | 
			
		||||
type SimpleLayout = {
 | 
			
		||||
    title: string
 | 
			
		||||
    intro: string
 | 
			
		||||
export type SimpleLayoutProps = {
 | 
			
		||||
    heading: string
 | 
			
		||||
    description: string
 | 
			
		||||
    children: ReactNode
 | 
			
		||||
    gradient: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function SimpleLayout({
 | 
			
		||||
                                 title,
 | 
			
		||||
                                 intro,
 | 
			
		||||
                                 heading,
 | 
			
		||||
                                 description,
 | 
			
		||||
                                 children,
 | 
			
		||||
                                 gradient
 | 
			
		||||
                             }: SimpleLayout) {
 | 
			
		||||
                             }: SimpleLayoutProps) {
 | 
			
		||||
    return (
 | 
			
		||||
        <Container className="mt-16 sm:mt-32">
 | 
			
		||||
            <header className="max-w-2xl">
 | 
			
		||||
@ -28,10 +28,10 @@ export function SimpleLayout({
 | 
			
		||||
                        sm:text-5xl
 | 
			
		||||
                        ${gradient ? `${gradient} bg-clip-text dark:text-transparent` : ''}
 | 
			
		||||
                    `)}>
 | 
			
		||||
                    {title}
 | 
			
		||||
                    {heading}
 | 
			
		||||
                </h1>
 | 
			
		||||
                <p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
 | 
			
		||||
                    {intro}
 | 
			
		||||
                    {description}
 | 
			
		||||
                </p>
 | 
			
		||||
            </header>
 | 
			
		||||
            <div className="mt-16 sm:mt-20">{children}</div>
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import {Props} from '@/types'
 | 
			
		||||
import clsx from 'clsx'
 | 
			
		||||
import Link from 'next/link'
 | 
			
		||||
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) {
 | 
			
		||||
    return (
 | 
			
		||||
@ -25,7 +25,7 @@ export function Avatar({large = false, className, ...props}: { large?: boolean,
 | 
			
		||||
            {...props}
 | 
			
		||||
        >
 | 
			
		||||
            <Image
 | 
			
		||||
                src={avatar}
 | 
			
		||||
                src={me}
 | 
			
		||||
                alt=""
 | 
			
		||||
                sizes={large ? '4rem' : '2.25rem'}
 | 
			
		||||
                className={clsx(
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,7 @@
 | 
			
		||||
'use client'
 | 
			
		||||
 | 
			
		||||
import React, {Fragment, ReactNode} from 'react'
 | 
			
		||||
import {useRouter} from 'next/router'
 | 
			
		||||
import {usePathname} from 'next/navigation'
 | 
			
		||||
import {Popover, Transition} from '@headlessui/react'
 | 
			
		||||
import clsx from 'clsx'
 | 
			
		||||
import Link from 'next/link'
 | 
			
		||||
@ -77,8 +79,8 @@ export function MobileNavigation(props: Props) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function NavItem({href, children}: { href: string } & Props) {
 | 
			
		||||
    const router = useRouter()
 | 
			
		||||
    let isActive = router.pathname === href
 | 
			
		||||
    const pathname = usePathname()
 | 
			
		||||
    let isActive = pathname === href
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <li>
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
'use client'
 | 
			
		||||
 | 
			
		||||
import useSWR from 'swr'
 | 
			
		||||
import fetcher from '@/lib/fetcher'
 | 
			
		||||
import Image from 'next/image'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										62
									
								
								components/ui/ThemeButton.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -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 useSWR, {useSWRConfig} from 'swr'
 | 
			
		||||
import fetcher from '@/lib/fetcher'
 | 
			
		||||
@ -12,15 +14,16 @@ type ViewsProps = {
 | 
			
		||||
    shouldRender?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const supabase = createPagesBrowserClient()
 | 
			
		||||
 | 
			
		||||
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 {mutate} = useSWRConfig()
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (shouldUpdateViews) {
 | 
			
		||||
            // subscribe to analytics table and react to updates at row level
 | 
			
		||||
            const sub = supabaseClient
 | 
			
		||||
            const sub = supabase
 | 
			
		||||
                .channel('any')
 | 
			
		||||
                .on('postgres_changes', {
 | 
			
		||||
                    event: 'UPDATE',
 | 
			
		||||
 | 
			
		||||
@ -1,20 +1,14 @@
 | 
			
		||||
import {GetServerSidePropsContext} from 'next'
 | 
			
		||||
import {createServerSupabaseClient} from '@supabase/auth-helpers-nextjs'
 | 
			
		||||
import {
 | 
			
		||||
    getTopRepo,
 | 
			
		||||
    getTotalFollowers,
 | 
			
		||||
    getTotalForks,
 | 
			
		||||
    getTotalRepos,
 | 
			
		||||
    getTotalStars
 | 
			
		||||
} from '@/lib/github'
 | 
			
		||||
import {cookies} from 'next/headers'
 | 
			
		||||
import {createServerComponentClient} from '@supabase/auth-helpers-nextjs'
 | 
			
		||||
import {getTopRepo, getTotalFollowers, getTotalForks, getTotalRepos, getTotalStars} from '@/lib/github'
 | 
			
		||||
import {getAllArticles} from '@/lib/getAllArticles'
 | 
			
		||||
import {getTopArtist, getTopGenre} from '@/lib/spotify'
 | 
			
		||||
import {getStats} from '@/lib/statsfm'
 | 
			
		||||
import {Metric} from '@/types'
 | 
			
		||||
import {getStats} from "@/lib/statsfm";
 | 
			
		||||
 | 
			
		||||
export async function getDashboardData(context: GetServerSidePropsContext) {
 | 
			
		||||
    const supabaseClient = createServerSupabaseClient(context)
 | 
			
		||||
    const {data: views} = await supabaseClient.rpc('total_views')
 | 
			
		||||
export async function getDashboardData() {
 | 
			
		||||
    const supabase = createServerComponentClient({cookies})
 | 
			
		||||
    const {data: views} = await supabase.rpc('total_views')
 | 
			
		||||
    const [totalRepos, totalFollowers] = await Promise.all([
 | 
			
		||||
        getTotalRepos(),
 | 
			
		||||
        getTotalFollowers()
 | 
			
		||||
@ -26,27 +20,27 @@ export async function getDashboardData(context: GetServerSidePropsContext) {
 | 
			
		||||
    const totalArticles = (await getAllArticles()).length
 | 
			
		||||
    const topArtist = await getTopArtist()
 | 
			
		||||
    const {genre} = await getTopGenre()
 | 
			
		||||
    // const {hoursListened, minutesListened, streams} = await getStats()
 | 
			
		||||
    const {hoursListened, minutesListened, streams} = await getStats()
 | 
			
		||||
 | 
			
		||||
    const metrics: Metric[] = [
 | 
			
		||||
        // {
 | 
			
		||||
        //     title: "Streams",
 | 
			
		||||
        //     value: +streams,
 | 
			
		||||
        //     group: "Spotify",
 | 
			
		||||
        //     href: "https://open.spotify.com/?"
 | 
			
		||||
        // },
 | 
			
		||||
        // {
 | 
			
		||||
        //     title: "Hours listened",
 | 
			
		||||
        //     value: +hoursListened,
 | 
			
		||||
        //     group: "Spotify",
 | 
			
		||||
        //     href: "https://open.spotify.com/?"
 | 
			
		||||
        // },
 | 
			
		||||
        // {
 | 
			
		||||
        //     title: "Minutes listened",
 | 
			
		||||
        //     value: +minutesListened,
 | 
			
		||||
        //     group: "Spotify",
 | 
			
		||||
        //     href: "https://open.spotify.com/?"
 | 
			
		||||
        // },
 | 
			
		||||
        {
 | 
			
		||||
            title: "Streams",
 | 
			
		||||
            value: +streams,
 | 
			
		||||
            group: "Spotify",
 | 
			
		||||
            href: "https://open.spotify.com/?"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: "Hours listened",
 | 
			
		||||
            value: +hoursListened,
 | 
			
		||||
            group: "Spotify",
 | 
			
		||||
            href: "https://open.spotify.com/?"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: "Minutes listened",
 | 
			
		||||
            value: +minutesListened,
 | 
			
		||||
            group: "Spotify",
 | 
			
		||||
            href: "https://open.spotify.com/?"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: "Top genre",
 | 
			
		||||
            value: genre,
 | 
			
		||||
 | 
			
		||||
@ -18,8 +18,8 @@ export async function generateRssFeed() {
 | 
			
		||||
        author,
 | 
			
		||||
        id: siteUrl!,
 | 
			
		||||
        link: siteUrl,
 | 
			
		||||
        image: `${siteUrl}/static/icons/favicon.ico`,
 | 
			
		||||
        favicon: `${siteUrl}/static/icons/favicon.ico`,
 | 
			
		||||
        image: `${siteUrl}/favicon.ico`,
 | 
			
		||||
        favicon: `${siteUrl}/favicon.ico`,
 | 
			
		||||
        copyright: `All rights reserved ${new Date().getFullYear()}`,
 | 
			
		||||
        feedLinks: {
 | 
			
		||||
            rss2: `${siteUrl}/rss/feed.xml`,
 | 
			
		||||
 | 
			
		||||
@ -2,19 +2,19 @@ import glob from 'fast-glob'
 | 
			
		||||
import * as path from 'path'
 | 
			
		||||
 | 
			
		||||
async function importArticle(articleFilename: string) {
 | 
			
		||||
    let {meta, default: component} = await import(
 | 
			
		||||
        `/pages/writing/${articleFilename}`
 | 
			
		||||
    let {metadata, default: component} = await import(
 | 
			
		||||
        `/app/writing/${articleFilename}`
 | 
			
		||||
        )
 | 
			
		||||
    return {
 | 
			
		||||
        slug: articleFilename.replace(/(\/index)?\.mdx$/, ''),
 | 
			
		||||
        ...meta,
 | 
			
		||||
        slug: articleFilename.replace(/(\/page)?\.mdx$/, ''),
 | 
			
		||||
        ...metadata,
 | 
			
		||||
        component,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getAllArticles() {
 | 
			
		||||
    let articleFilenames = await glob(['*.mdx', '*/index.mdx'], {
 | 
			
		||||
        cwd: path.join(process.cwd(), './pages/writing'),
 | 
			
		||||
    let articleFilenames = await glob(['*.mdx', '*/page.mdx'], {
 | 
			
		||||
        cwd: path.join(process.cwd(), './app/writing'),
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    let articles = await Promise.all(articleFilenames.map(importArticle))
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								mdx-components.tsx
									
									
									
									
									
										Normal 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
									
								
							
							
						
						@ -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
									
									
									
								
							
							
						
						
							
								
								
									
										19
									
								
								package.json
									
									
									
									
									
								
							
							
						
						@ -12,25 +12,28 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@headlessui/react": "^1.7.7",
 | 
			
		||||
    "@mapbox/rehype-prism": "^0.7.0",
 | 
			
		||||
    "@next/mdx": "^13.1.1",
 | 
			
		||||
    "@supabase/auth-helpers-nextjs": "^0.6.0",
 | 
			
		||||
    "@supabase/auth-helpers-react": "^0.3.1",
 | 
			
		||||
    "@mdx-js/loader": "^2.3.0",
 | 
			
		||||
    "@mdx-js/react": "^2.3.0",
 | 
			
		||||
    "@next/mdx": "^13.4.10",
 | 
			
		||||
    "@supabase/auth-helpers-nextjs": "^0.7.3",
 | 
			
		||||
    "@supabase/supabase-js": "^2.27.0",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.8",
 | 
			
		||||
    "@types/mdx": "^2.0.3",
 | 
			
		||||
    "@types/mdx": "^2.0.5",
 | 
			
		||||
    "@types/node": "^18.11.18",
 | 
			
		||||
    "@types/nprogress": "^0.2.0",
 | 
			
		||||
    "@types/react": "18.0.26",
 | 
			
		||||
    "@types/react-dom": "18.0.10",
 | 
			
		||||
    "autoprefixer": "^10.4.13",
 | 
			
		||||
    "clsx": "^1.2.1",
 | 
			
		||||
    "encoding": "^0.1.13",
 | 
			
		||||
    "eslint": "8.31.0",
 | 
			
		||||
    "eslint-config-next": "^13.3.0",
 | 
			
		||||
    "fast-glob": "^3.2.12",
 | 
			
		||||
    "feed": "^4.2.2",
 | 
			
		||||
    "focus-visible": "^5.2.0",
 | 
			
		||||
    "motion": "^10.15.5",
 | 
			
		||||
    "next": "^13.3.0",
 | 
			
		||||
    "node-fetch": "^3.3.0",
 | 
			
		||||
    "next": "^13.4.10",
 | 
			
		||||
    "next-themes": "^0.2.1",
 | 
			
		||||
    "nprogress": "^0.2.0",
 | 
			
		||||
    "postcss": "^8.4.21",
 | 
			
		||||
    "postcss-focus-visible": "^7.1.0",
 | 
			
		||||
@ -38,13 +41,11 @@
 | 
			
		||||
    "react-dom": "^18.2.0",
 | 
			
		||||
    "remark-gfm": "^3.0.1",
 | 
			
		||||
    "sharp": "^0.31.3",
 | 
			
		||||
    "supabase": "^1.50.2",
 | 
			
		||||
    "swr": "^2.1.2",
 | 
			
		||||
    "tailwind-merge": "^1.9.0",
 | 
			
		||||
    "tailwindcss": "^3.3.0",
 | 
			
		||||
    "ts-node": "^10.9.1",
 | 
			
		||||
    "typescript": "4.9.4"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "supabase": "^1.50.2"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										146
									
								
								pages/about.tsx
									
									
									
									
									
								
							
							
						
						@ -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="I’m 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="I’m 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">
 | 
			
		||||
                            I’m 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'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'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'm not tinkering, I mostly spend time with my
 | 
			
		||||
                                family and enjoy travelling whenever I can get away.
 | 
			
		||||
                            </p>
 | 
			
		||||
                            <p>
 | 
			
		||||
                                That'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'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>
 | 
			
		||||
        </>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
}
 | 
			
		||||
@ -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({})
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										122
									
								
								pages/uses.tsx
									
									
									
									
									
								
							
							
						
						@ -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. Here’s 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'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'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'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'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'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'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'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'm using, I always have the
 | 
			
		||||
                            same set of bookmarks.
 | 
			
		||||
                        </Tool>
 | 
			
		||||
                    </ToolsSection>
 | 
			
		||||
                </div>
 | 
			
		||||
            </SimpleLayout>
 | 
			
		||||
        </>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -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),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB  | 
| 
		 Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 239 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								public/images/me.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 161 KiB  | 
| 
		 Before Width: | Height: | Size: 53 KiB  | 
| 
		 Before Width: | Height: | Size: 850 B  | 
| 
		 Before Width: | Height: | Size: 2.4 KiB  | 
| 
		 Before Width: | Height: | Size: 34 KiB  | 
| 
		 Before Width: | Height: | Size: 291 KiB  | 
| 
		 Before Width: | Height: | Size: 166 KiB  | 
| 
		 Before Width: | Height: | Size: 482 KiB  | 
@ -1,7 +1,11 @@
 | 
			
		||||
import type {Config} from 'tailwindcss'
 | 
			
		||||
 | 
			
		||||
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',
 | 
			
		||||
    plugins: [
 | 
			
		||||
        require('@tailwindcss/typography')
 | 
			
		||||
 | 
			
		||||
@ -23,12 +23,18 @@
 | 
			
		||||
      "@/*": [
 | 
			
		||||
        "*"
 | 
			
		||||
      ]
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
    "plugins": [
 | 
			
		||||
      {
 | 
			
		||||
        "name": "next"
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "include": [
 | 
			
		||||
    "next-env.d.ts",
 | 
			
		||||
    "**/*.ts",
 | 
			
		||||
    "**/*.tsx"
 | 
			
		||||
    "**/*.tsx",
 | 
			
		||||
    ".next/types/**/*.ts"
 | 
			
		||||
  ],
 | 
			
		||||
  "exclude": [
 | 
			
		||||
    "node_modules"
 | 
			
		||||
 | 
			
		||||