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 {getCurrentlyPlaying} from '@/lib/spotify'
|
||||||
import {NextApiRequest, NextApiResponse} from 'next'
|
|
||||||
|
|
||||||
type Song = {
|
type Song = {
|
||||||
item: {
|
item: {
|
||||||
@ -24,24 +24,18 @@ type Song = {
|
|||||||
is_playing: boolean
|
is_playing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export async function GET(request: Request) {
|
||||||
const response = await getCurrentlyPlaying()
|
const response = await getCurrentlyPlaying()
|
||||||
|
|
||||||
if (response.status === 204 || response.status > 400) {
|
if (response.status === 204 || response.status > 400) {
|
||||||
return res.status(200).json({
|
return new Response(JSON.stringify({isPlaying: false}), {
|
||||||
isPlaying: false
|
status: 200
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const song = await response.json() as Song
|
const song = await response.json() as Song
|
||||||
const {item} = song
|
const {item} = song
|
||||||
|
|
||||||
if (item === null) {
|
|
||||||
return res.status(200).json({
|
|
||||||
isPlaying: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const artist = item.artists.map(artist => artist.name).join(', ')
|
const artist = item.artists.map(artist => artist.name).join(', ')
|
||||||
const title = item.name;
|
const title = item.name;
|
||||||
const songUrl = item.external_urls.spotify
|
const songUrl = item.external_urls.spotify
|
||||||
@ -49,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
const albumImageUrl = item.album.images[0].url
|
const albumImageUrl = item.album.images[0].url
|
||||||
const isPlaying = song.is_playing;
|
const isPlaying = song.is_playing;
|
||||||
|
|
||||||
return res.status(200).json({
|
return NextResponse.json({
|
||||||
artist,
|
artist,
|
||||||
title,
|
title,
|
||||||
songUrl,
|
songUrl,
|
@ -1,5 +1,5 @@
|
|||||||
|
import {NextResponse} from 'next/server'
|
||||||
import {getRecentlyPlayed} from '@/lib/spotify'
|
import {getRecentlyPlayed} from '@/lib/spotify'
|
||||||
import {NextApiRequest, NextApiResponse} from 'next'
|
|
||||||
|
|
||||||
type Tracks = {
|
type Tracks = {
|
||||||
items: [
|
items: [
|
||||||
@ -28,19 +28,16 @@ type Tracks = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export async function GET(request: Request) {
|
||||||
const response = await getRecentlyPlayed()
|
const response = await getRecentlyPlayed()
|
||||||
|
|
||||||
if (response.status > 400) {
|
if (response.status > 400) {
|
||||||
return res.status(200).json({})
|
return new Response(JSON.stringify({status: response.statusText}), {
|
||||||
|
status: response.status
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = await response.json() as Tracks
|
const tracks = await response.json() as Tracks
|
||||||
|
|
||||||
if (tracks === null) {
|
|
||||||
return res.status(200).json({})
|
|
||||||
}
|
|
||||||
|
|
||||||
const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a)
|
const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a)
|
||||||
|
|
||||||
const title = track.name;
|
const title = track.name;
|
||||||
@ -49,7 +46,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
const album = track.album.name
|
const album = track.album.name
|
||||||
const albumImageUrl = track.album.images[0].url
|
const albumImageUrl = track.album.images[0].url
|
||||||
|
|
||||||
return res.status(200).json({
|
return NextResponse.json({
|
||||||
artist,
|
artist,
|
||||||
title,
|
title,
|
||||||
songUrl,
|
songUrl,
|
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 React from 'react'
|
||||||
import Head from 'next/head'
|
|
||||||
import {GetStaticProps} from 'next'
|
|
||||||
import {Card} from '@/components/ui/Card'
|
import {Card} from '@/components/ui/Card'
|
||||||
import {Resume} from '@/components/ui/Resume'
|
|
||||||
import {Container} from '@/components/common/Container'
|
|
||||||
import {
|
|
||||||
GitHubIcon,
|
|
||||||
LinkedInIcon
|
|
||||||
} from '@/components/icons/SocialIcons'
|
|
||||||
import {SocialLink} from '@/components/ui/SocialLink'
|
|
||||||
import {Views} from '@/components/ui/Views'
|
import {Views} from '@/components/ui/Views'
|
||||||
import {formatDate} from '@/lib/formatDate'
|
import {Resume} from '@/components/ui/Resume'
|
||||||
import {generateRssFeed} from '@/lib/generateRssFeed'
|
import {SocialLink} from '@/components/ui/SocialLink'
|
||||||
import {generateSitemap} from '@/lib/generateSitemap'
|
import {Container} from '@/components/common/Container'
|
||||||
|
import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
|
||||||
import {getAllArticles} from '@/lib/getAllArticles'
|
import {getAllArticles} from '@/lib/getAllArticles'
|
||||||
import {Article} from 'types'
|
import {formatDate} from '@/lib/formatDate'
|
||||||
|
import type {Article} from '@/types'
|
||||||
|
|
||||||
function Article(article: Article) {
|
function Article(article: Article) {
|
||||||
return (
|
return (
|
||||||
@ -38,58 +31,13 @@ function Article(article: Article) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({articles}: { articles: Article[] }) {
|
export default async function Home() {
|
||||||
|
const articles = (await getAllArticles())
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(component => component)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
|
||||||
<title>
|
|
||||||
Ryan Freeman - Full-stack software engineer from Dublin, Ireland.
|
|
||||||
</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Full-stack software engineer who enjoys building cloud-native applications."
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:url"
|
|
||||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:type"
|
|
||||||
content="website"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:title"
|
|
||||||
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:description"
|
|
||||||
content="Full-stack software engineer who enjoys building cloud-native applications."
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:image"
|
|
||||||
content="/static/images/photo-of-me-og.jpg"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="twitter:card"
|
|
||||||
content="summary_large_image"/>
|
|
||||||
<meta
|
|
||||||
property="twitter:domain"
|
|
||||||
content="ryanfreeman.dev"/>
|
|
||||||
<meta
|
|
||||||
property="twitter:url"
|
|
||||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="twitter:title"
|
|
||||||
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."/>
|
|
||||||
<meta
|
|
||||||
name="twitter:description"
|
|
||||||
content="Full-stack software engineer who enjoys building cloud-native applications."/>
|
|
||||||
<meta
|
|
||||||
name="twitter:image"
|
|
||||||
content="/static/images/photo-of-me-og.jpg"
|
|
||||||
/>
|
|
||||||
</Head>
|
|
||||||
<Container className="mt-9">
|
<Container className="mt-9">
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500">
|
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500">
|
||||||
@ -135,19 +83,4 @@ export default function Home({articles}: { articles: Article[] }) {
|
|||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
await generateRssFeed()
|
|
||||||
await generateSitemap()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
articles: (await getAllArticles())
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(({component, ...meta}) => meta),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
57
app/projects/page.tsx
Normal file
@ -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 {ArticleLayout} from '../../../components/layouts/ArticleLayout'
|
||||||
import {createSlug} from '@/lib/createSlug'
|
import {createSlug} from '../../../lib/createSlug'
|
||||||
|
|
||||||
export const meta = {
|
export const metadata = {
|
||||||
author: 'Ryan Freeman',
|
authors: 'Ryan Freeman',
|
||||||
date: '2022-12-04',
|
|
||||||
title: 'A personal journey in software engineering',
|
title: 'A personal journey in software engineering',
|
||||||
description: 'Hello there! If you\'re reading this, you\'ve likely stumbled upon my website — welcome! My name is Ryan Freeman, and I\'m a full-stack developer with a passion for creating intuitive and dynamic web applications.',
|
date: '2022-12-04',
|
||||||
|
description: 'Hello there! If you\'re reading this, you\'ve likely stumbled upon my website — welcome! My name is Ryan Freeman, and I\'m a full-stack developer with a passion for creating intuitive and dynamic web applications.'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props) => <ArticleLayout
|
export default (props) => <ArticleLayout
|
||||||
author={meta.author}
|
title={metadata.title}
|
||||||
date={meta.date}
|
date={metadata.date}
|
||||||
title={meta.title}
|
description={metadata.description}
|
||||||
description={meta.description}
|
slug={createSlug(metadata.title)}
|
||||||
slug={createSlug(meta.title)}
|
|
||||||
{...props} />
|
{...props} />
|
||||||
|
|
||||||
Hello there!
|
Hello there!
|
Before Width: | Height: | Size: 567 KiB After Width: | Height: | Size: 567 KiB |
@ -1,23 +1,23 @@
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import {ArticleLayout} from '@/components/layouts/ArticleLayout'
|
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
|
||||||
import {createSlug} from '@/lib/createSlug'
|
import {createSlug} from '../../../lib/createSlug'
|
||||||
import cargoShipImage from './ian-taylor-jOqJbvo1P9g-unsplash.jpg'
|
import cargoShipImage from './ian-taylor-jOqJbvo1P9g-unsplash.jpg'
|
||||||
|
|
||||||
export const meta = {
|
export const metadata = {
|
||||||
author: 'Ryan Freeman',
|
author: 'Ryan Freeman',
|
||||||
date: '2023-02-11',
|
date: '2023-02-11',
|
||||||
title: 'Docker cheat sheet',
|
title: 'Docker cheat sheet',
|
||||||
description: 'This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.',
|
description: 'This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.',
|
||||||
ogImage: '/static/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg'
|
ogImage: '/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props) => <ArticleLayout
|
export default (props) => <ArticleLayout
|
||||||
author={meta.author}
|
author={metadata.author}
|
||||||
date={meta.date}
|
date={metadata.date}
|
||||||
title={meta.title}
|
title={metadata.title}
|
||||||
description={meta.description}
|
description={metadata.description}
|
||||||
ogImage={meta.ogImage}
|
ogImage={metadata.ogImage}
|
||||||
slug={createSlug(meta.title)}
|
slug={createSlug(metadata.title)}
|
||||||
{...props} />
|
{...props} />
|
||||||
|
|
||||||
This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.
|
This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.
|
@ -1,7 +1,7 @@
|
|||||||
import {ArticleLayout} from '@/components/layouts/ArticleLayout'
|
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
|
||||||
import {createSlug} from '@/lib/createSlug'
|
import {createSlug} from '../../../lib/createSlug'
|
||||||
|
|
||||||
export const meta = {
|
export const metadata = {
|
||||||
author: 'Ryan Freeman',
|
author: 'Ryan Freeman',
|
||||||
date: '2023-01-02',
|
date: '2023-01-02',
|
||||||
title: 'How to add TypeScript to an existing Next.js project',
|
title: 'How to add TypeScript to an existing Next.js project',
|
||||||
@ -9,11 +9,11 @@ export const meta = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default (props) => <ArticleLayout
|
export default (props) => <ArticleLayout
|
||||||
author={meta.author}
|
author={metadata.author}
|
||||||
date={meta.date}
|
date={metadata.date}
|
||||||
title={meta.title}
|
title={metadata.title}
|
||||||
description={meta.description}
|
description={metadata.description}
|
||||||
slug={createSlug(meta.title)}
|
slug={createSlug(metadata.title)}
|
||||||
{...props} />
|
{...props} />
|
||||||
|
|
||||||
# Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`.
|
# Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`.
|
54
app/writing/page.tsx
Normal file
@ -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 {useEffect, useRef} from 'react'
|
||||||
|
import {usePathname} from 'next/navigation'
|
||||||
import {Container} from './Container'
|
import {Container} from './Container'
|
||||||
import {MobileNavigation, DesktopNavigation} from '@/components/ui/Navigation'
|
import {ThemeButton} from '@/components/ui/ThemeButton'
|
||||||
|
import {DesktopNavigation, MobileNavigation} from '@/components/ui/Navigation'
|
||||||
import {Avatar, AvatarContainer} from '@/components/ui/Avatar'
|
import {Avatar, AvatarContainer} from '@/components/ui/Avatar'
|
||||||
import {MoonIcon} from '@/components/icons/MoonIcon'
|
|
||||||
import {SunIcon} from '@/components/icons/SunIcon'
|
|
||||||
|
|
||||||
function ModeToggle() {
|
|
||||||
function disableTransitionsTemporarily() {
|
|
||||||
document.documentElement.classList.add('[&_*]:!transition-none')
|
|
||||||
window.setTimeout(() => {
|
|
||||||
document.documentElement.classList.remove('[&_*]:!transition-none')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMode() {
|
|
||||||
disableTransitionsTemporarily()
|
|
||||||
|
|
||||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
let isSystemDarkMode = darkModeMediaQuery.matches
|
|
||||||
let isDarkMode = document.documentElement.classList.toggle('dark')
|
|
||||||
|
|
||||||
if (isDarkMode === isSystemDarkMode) {
|
|
||||||
delete window.localStorage.isDarkMode
|
|
||||||
} else {
|
|
||||||
window.localStorage.isDarkMode = isDarkMode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Toggle dark mode"
|
|
||||||
className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
|
|
||||||
onClick={toggleMode}
|
|
||||||
>
|
|
||||||
<SunIcon
|
|
||||||
className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-indigo-50 [@media(prefers-color-scheme:dark)]:stroke-indigo-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-indigo-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-indigo-600"/>
|
|
||||||
<MoonIcon
|
|
||||||
className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-indigo-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-indigo-500"/>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(num: number, a: number, b: number) {
|
function clamp(num: number, a: number, b: number) {
|
||||||
let min = Math.min(a, b)
|
let min = Math.min(a, b)
|
||||||
@ -50,8 +14,8 @@ function clamp(num: number, a: number, b: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const router = useRouter()
|
const pathname = usePathname()
|
||||||
const isHomePage = router.pathname === '/'
|
const isHomePage = pathname === '/'
|
||||||
|
|
||||||
const headerRef = useRef<HTMLDivElement>(null)
|
const headerRef = useRef<HTMLDivElement>(null)
|
||||||
const avatarRef = useRef<HTMLImageElement>(null)
|
const avatarRef = useRef<HTMLImageElement>(null)
|
||||||
@ -227,7 +191,7 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end md:flex-1">
|
<div className="flex justify-end md:flex-1">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<ModeToggle/>
|
<ThemeButton/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import React, {ReactNode} from 'react'
|
import React, {ReactNode} from 'react'
|
||||||
import * as process from 'process'
|
|
||||||
import Head from 'next/head'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {Container} from '@/components/common/Container'
|
import {Container} from '@/components/common/Container'
|
||||||
import {Prose} from '@/components/ui/Prose'
|
import {Prose} from '@/components/ui/Prose'
|
||||||
@ -9,112 +7,66 @@ import {ArrowDownIcon} from '@/components/icons/ArrowDownIcon'
|
|||||||
import {formatDate} from '@/lib/formatDate'
|
import {formatDate} from '@/lib/formatDate'
|
||||||
|
|
||||||
type ArticleLayout = {
|
type ArticleLayout = {
|
||||||
children?: ReactNode
|
|
||||||
isRssFeed: boolean
|
|
||||||
title: string
|
title: string
|
||||||
description: string
|
|
||||||
ogImage: string
|
|
||||||
date: string
|
date: string
|
||||||
|
description: string
|
||||||
slug: string
|
slug: string
|
||||||
|
children?: ReactNode
|
||||||
|
ogImage?: string
|
||||||
|
isRssFeed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gradients = [
|
||||||
|
'bg-gradient-to-r from-blue-500 to-blue-600',
|
||||||
|
'bg-[conic-gradient(at_left,_var(--tw-gradient-stops))] from-rose-500 to-indigo-700',
|
||||||
|
'bg-[conic-gradient(at_left,_var(--tw-gradient-stops))] from-sky-400 to-blue-800',
|
||||||
|
'bg-gradient-to-r from-orange-400 to-rose-400',
|
||||||
|
'bg-gradient-to-r from-sky-400 to-blue-500'
|
||||||
|
]
|
||||||
|
|
||||||
export function ArticleLayout({
|
export function ArticleLayout({
|
||||||
children,
|
|
||||||
isRssFeed = false,
|
|
||||||
title,
|
title,
|
||||||
description,
|
|
||||||
ogImage,
|
|
||||||
date,
|
date,
|
||||||
slug
|
description,
|
||||||
|
slug,
|
||||||
|
children,
|
||||||
|
ogImage,
|
||||||
|
isRssFeed = false
|
||||||
}: ArticleLayout) {
|
}: ArticleLayout) {
|
||||||
if (isRssFeed) {
|
if (isRssFeed) {
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Container className="mt-16 lg:mt-32">
|
||||||
<Head>
|
<div className="xl:relative">
|
||||||
<title>{`${title} - Ryan Freeman`}</title>
|
<div className="mx-auto max-w-2xl">
|
||||||
<meta name="description" content={description}/>
|
<Link href="/writing">
|
||||||
<meta
|
<button
|
||||||
property="og:url"
|
type="button"
|
||||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}/writing/${slug}`}
|
aria-label="Go back to articles"
|
||||||
/>
|
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:mb-0 lg:-mt-2 xl:-top-1.5 xl:left-0 xl:mt-0"
|
||||||
<meta
|
>
|
||||||
property="og:type"
|
<ArrowDownIcon
|
||||||
content="website"
|
className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400 rotate-90"/>
|
||||||
/>
|
</button>
|
||||||
<meta
|
</Link>
|
||||||
property="og:title"
|
<article>
|
||||||
content={title}
|
<header className="flex flex-col">
|
||||||
/>
|
<h1 className={`mt-6 text-4xl font-bold tracking-tight sm:text-5xl bg-clip-text dark:text-transparent ${gradients[Math.floor(gradients.length * Math.random())]}`}>
|
||||||
<meta
|
{title}
|
||||||
property="og:description"
|
</h1>
|
||||||
content={description}
|
<p className="order-first text-base text-zinc-500 dark:text-zinc-400">
|
||||||
/>
|
<time dateTime={date}>
|
||||||
{ogImage &&
|
<span>{formatDate(date)}</span>
|
||||||
<>
|
</time>
|
||||||
<meta
|
<Views slug={slug} shouldUpdateViews={true}/>
|
||||||
property="og:image"
|
</p>
|
||||||
content={ogImage}
|
</header>
|
||||||
/>
|
<Prose className="mt-8">{children}</Prose>
|
||||||
<meta
|
</article>
|
||||||
name="twitter:card"
|
|
||||||
content="summary_large_image"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="twitter:image"
|
|
||||||
content={ogImage}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
<meta
|
|
||||||
property="twitter:domain"
|
|
||||||
content="ryanfreeman.dev"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="twitter:url"
|
|
||||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}/writing/${slug}`}
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="twitter:title"
|
|
||||||
content={title}
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="twitter:description"
|
|
||||||
content={description}
|
|
||||||
/>
|
|
||||||
</Head>
|
|
||||||
<Container className="mt-16 lg:mt-32">
|
|
||||||
<div className="xl:relative">
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
<Link href="/writing" replace>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Go back to articles"
|
|
||||||
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:mb-0 lg:-mt-2 xl:-top-1.5 xl:left-0 xl:mt-0"
|
|
||||||
>
|
|
||||||
<ArrowDownIcon
|
|
||||||
className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400 rotate-90"/>
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
<article>
|
|
||||||
<header className="flex flex-col">
|
|
||||||
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="order-first text-base text-zinc-500 dark:text-zinc-400">
|
|
||||||
<time dateTime={date}>
|
|
||||||
<span>{formatDate(date)}</span>
|
|
||||||
</time>
|
|
||||||
<Views slug={slug} shouldUpdateViews={true}/>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<Prose className="mt-8">{children}</Prose>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
</>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -2,19 +2,19 @@ import {ReactNode} from 'react'
|
|||||||
import {Container} from '@/components/common/Container'
|
import {Container} from '@/components/common/Container'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
type SimpleLayout = {
|
export type SimpleLayoutProps = {
|
||||||
title: string
|
heading: string
|
||||||
intro: string
|
description: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
gradient: string
|
gradient: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimpleLayout({
|
export function SimpleLayout({
|
||||||
title,
|
heading,
|
||||||
intro,
|
description,
|
||||||
children,
|
children,
|
||||||
gradient
|
gradient
|
||||||
}: SimpleLayout) {
|
}: SimpleLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<Container className="mt-16 sm:mt-32">
|
<Container className="mt-16 sm:mt-32">
|
||||||
<header className="max-w-2xl">
|
<header className="max-w-2xl">
|
||||||
@ -28,10 +28,10 @@ export function SimpleLayout({
|
|||||||
sm:text-5xl
|
sm:text-5xl
|
||||||
${gradient ? `${gradient} bg-clip-text dark:text-transparent` : ''}
|
${gradient ? `${gradient} bg-clip-text dark:text-transparent` : ''}
|
||||||
`)}>
|
`)}>
|
||||||
{title}
|
{heading}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
||||||
{intro}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div className="mt-16 sm:mt-20">{children}</div>
|
<div className="mt-16 sm:mt-20">{children}</div>
|
||||||
|
@ -2,7 +2,7 @@ import {Props} from '@/types'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import avatar from '@/public/static/images/avatar.jpg'
|
import me from '/public/images/me.jpg'
|
||||||
|
|
||||||
export function AvatarContainer({className, ...props}: { style?: Object } & Props) {
|
export function AvatarContainer({className, ...props}: { style?: Object } & Props) {
|
||||||
return (
|
return (
|
||||||
@ -25,7 +25,7 @@ export function Avatar({large = false, className, ...props}: { large?: boolean,
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={me}
|
||||||
alt=""
|
alt=""
|
||||||
sizes={large ? '4rem' : '2.25rem'}
|
sizes={large ? '4rem' : '2.25rem'}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import React, {Fragment, ReactNode} from 'react'
|
import React, {Fragment, ReactNode} from 'react'
|
||||||
import {useRouter} from 'next/router'
|
import {usePathname} from 'next/navigation'
|
||||||
import {Popover, Transition} from '@headlessui/react'
|
import {Popover, Transition} from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -77,8 +79,8 @@ export function MobileNavigation(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({href, children}: { href: string } & Props) {
|
function NavItem({href, children}: { href: string } & Props) {
|
||||||
const router = useRouter()
|
const pathname = usePathname()
|
||||||
let isActive = router.pathname === href
|
let isActive = pathname === href
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
import fetcher from '@/lib/fetcher'
|
import fetcher from '@/lib/fetcher'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
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 {ElementType, useEffect} from 'react'
|
||||||
import useSWR, {useSWRConfig} from 'swr'
|
import useSWR, {useSWRConfig} from 'swr'
|
||||||
import fetcher from '@/lib/fetcher'
|
import fetcher from '@/lib/fetcher'
|
||||||
@ -12,15 +14,16 @@ type ViewsProps = {
|
|||||||
shouldRender?: boolean
|
shouldRender?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supabase = createPagesBrowserClient()
|
||||||
|
|
||||||
export function Views({as: Component = 'span', slug, className, shouldUpdateViews = true, shouldRender = true}: ViewsProps) {
|
export function Views({as: Component = 'span', slug, className, shouldUpdateViews = true, shouldRender = true}: ViewsProps) {
|
||||||
const supabaseClient = useSupabaseClient()
|
|
||||||
const {data} = useSWR(`/api/views/${slug}`, fetcher) as { data: { views: number } }
|
const {data} = useSWR(`/api/views/${slug}`, fetcher) as { data: { views: number } }
|
||||||
const {mutate} = useSWRConfig()
|
const {mutate} = useSWRConfig()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldUpdateViews) {
|
if (shouldUpdateViews) {
|
||||||
// subscribe to analytics table and react to updates at row level
|
// subscribe to analytics table and react to updates at row level
|
||||||
const sub = supabaseClient
|
const sub = supabase
|
||||||
.channel('any')
|
.channel('any')
|
||||||
.on('postgres_changes', {
|
.on('postgres_changes', {
|
||||||
event: 'UPDATE',
|
event: 'UPDATE',
|
||||||
|
@ -1,20 +1,14 @@
|
|||||||
import {GetServerSidePropsContext} from 'next'
|
import {cookies} from 'next/headers'
|
||||||
import {createServerSupabaseClient} from '@supabase/auth-helpers-nextjs'
|
import {createServerComponentClient} from '@supabase/auth-helpers-nextjs'
|
||||||
import {
|
import {getTopRepo, getTotalFollowers, getTotalForks, getTotalRepos, getTotalStars} from '@/lib/github'
|
||||||
getTopRepo,
|
|
||||||
getTotalFollowers,
|
|
||||||
getTotalForks,
|
|
||||||
getTotalRepos,
|
|
||||||
getTotalStars
|
|
||||||
} from '@/lib/github'
|
|
||||||
import {getAllArticles} from '@/lib/getAllArticles'
|
import {getAllArticles} from '@/lib/getAllArticles'
|
||||||
import {getTopArtist, getTopGenre} from '@/lib/spotify'
|
import {getTopArtist, getTopGenre} from '@/lib/spotify'
|
||||||
import {getStats} from '@/lib/statsfm'
|
|
||||||
import {Metric} from '@/types'
|
import {Metric} from '@/types'
|
||||||
|
import {getStats} from "@/lib/statsfm";
|
||||||
|
|
||||||
export async function getDashboardData(context: GetServerSidePropsContext) {
|
export async function getDashboardData() {
|
||||||
const supabaseClient = createServerSupabaseClient(context)
|
const supabase = createServerComponentClient({cookies})
|
||||||
const {data: views} = await supabaseClient.rpc('total_views')
|
const {data: views} = await supabase.rpc('total_views')
|
||||||
const [totalRepos, totalFollowers] = await Promise.all([
|
const [totalRepos, totalFollowers] = await Promise.all([
|
||||||
getTotalRepos(),
|
getTotalRepos(),
|
||||||
getTotalFollowers()
|
getTotalFollowers()
|
||||||
@ -26,27 +20,27 @@ export async function getDashboardData(context: GetServerSidePropsContext) {
|
|||||||
const totalArticles = (await getAllArticles()).length
|
const totalArticles = (await getAllArticles()).length
|
||||||
const topArtist = await getTopArtist()
|
const topArtist = await getTopArtist()
|
||||||
const {genre} = await getTopGenre()
|
const {genre} = await getTopGenre()
|
||||||
// const {hoursListened, minutesListened, streams} = await getStats()
|
const {hoursListened, minutesListened, streams} = await getStats()
|
||||||
|
|
||||||
const metrics: Metric[] = [
|
const metrics: Metric[] = [
|
||||||
// {
|
{
|
||||||
// title: "Streams",
|
title: "Streams",
|
||||||
// value: +streams,
|
value: +streams,
|
||||||
// group: "Spotify",
|
group: "Spotify",
|
||||||
// href: "https://open.spotify.com/?"
|
href: "https://open.spotify.com/?"
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// title: "Hours listened",
|
title: "Hours listened",
|
||||||
// value: +hoursListened,
|
value: +hoursListened,
|
||||||
// group: "Spotify",
|
group: "Spotify",
|
||||||
// href: "https://open.spotify.com/?"
|
href: "https://open.spotify.com/?"
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// title: "Minutes listened",
|
title: "Minutes listened",
|
||||||
// value: +minutesListened,
|
value: +minutesListened,
|
||||||
// group: "Spotify",
|
group: "Spotify",
|
||||||
// href: "https://open.spotify.com/?"
|
href: "https://open.spotify.com/?"
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
title: "Top genre",
|
title: "Top genre",
|
||||||
value: genre,
|
value: genre,
|
||||||
|
@ -18,8 +18,8 @@ export async function generateRssFeed() {
|
|||||||
author,
|
author,
|
||||||
id: siteUrl!,
|
id: siteUrl!,
|
||||||
link: siteUrl,
|
link: siteUrl,
|
||||||
image: `${siteUrl}/static/icons/favicon.ico`,
|
image: `${siteUrl}/favicon.ico`,
|
||||||
favicon: `${siteUrl}/static/icons/favicon.ico`,
|
favicon: `${siteUrl}/favicon.ico`,
|
||||||
copyright: `All rights reserved ${new Date().getFullYear()}`,
|
copyright: `All rights reserved ${new Date().getFullYear()}`,
|
||||||
feedLinks: {
|
feedLinks: {
|
||||||
rss2: `${siteUrl}/rss/feed.xml`,
|
rss2: `${siteUrl}/rss/feed.xml`,
|
||||||
|
@ -2,19 +2,19 @@ import glob from 'fast-glob'
|
|||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
|
||||||
async function importArticle(articleFilename: string) {
|
async function importArticle(articleFilename: string) {
|
||||||
let {meta, default: component} = await import(
|
let {metadata, default: component} = await import(
|
||||||
`/pages/writing/${articleFilename}`
|
`/app/writing/${articleFilename}`
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
slug: articleFilename.replace(/(\/index)?\.mdx$/, ''),
|
slug: articleFilename.replace(/(\/page)?\.mdx$/, ''),
|
||||||
...meta,
|
...metadata,
|
||||||
component,
|
component,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllArticles() {
|
export async function getAllArticles() {
|
||||||
let articleFilenames = await glob(['*.mdx', '*/index.mdx'], {
|
let articleFilenames = await glob(['*.mdx', '*/page.mdx'], {
|
||||||
cwd: path.join(process.cwd(), './pages/writing'),
|
cwd: path.join(process.cwd(), './app/writing'),
|
||||||
})
|
})
|
||||||
|
|
||||||
let articles = await Promise.all(articleFilenames.map(importArticle))
|
let articles = await Promise.all(articleFilenames.map(importArticle))
|
||||||
|
15
mdx-components.tsx
Normal file
@ -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": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.7",
|
"@headlessui/react": "^1.7.7",
|
||||||
"@mapbox/rehype-prism": "^0.7.0",
|
"@mapbox/rehype-prism": "^0.7.0",
|
||||||
"@next/mdx": "^13.1.1",
|
"@mdx-js/loader": "^2.3.0",
|
||||||
"@supabase/auth-helpers-nextjs": "^0.6.0",
|
"@mdx-js/react": "^2.3.0",
|
||||||
"@supabase/auth-helpers-react": "^0.3.1",
|
"@next/mdx": "^13.4.10",
|
||||||
|
"@supabase/auth-helpers-nextjs": "^0.7.3",
|
||||||
|
"@supabase/supabase-js": "^2.27.0",
|
||||||
"@tailwindcss/typography": "^0.5.8",
|
"@tailwindcss/typography": "^0.5.8",
|
||||||
"@types/mdx": "^2.0.3",
|
"@types/mdx": "^2.0.5",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
"@types/nprogress": "^0.2.0",
|
"@types/nprogress": "^0.2.0",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
|
"encoding": "^0.1.13",
|
||||||
"eslint": "8.31.0",
|
"eslint": "8.31.0",
|
||||||
"eslint-config-next": "^13.3.0",
|
"eslint-config-next": "^13.3.0",
|
||||||
"fast-glob": "^3.2.12",
|
"fast-glob": "^3.2.12",
|
||||||
"feed": "^4.2.2",
|
"feed": "^4.2.2",
|
||||||
"focus-visible": "^5.2.0",
|
"focus-visible": "^5.2.0",
|
||||||
"motion": "^10.15.5",
|
"motion": "^10.15.5",
|
||||||
"next": "^13.3.0",
|
"next": "^13.4.10",
|
||||||
"node-fetch": "^3.3.0",
|
"next-themes": "^0.2.1",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"postcss-focus-visible": "^7.1.0",
|
"postcss-focus-visible": "^7.1.0",
|
||||||
@ -38,13 +41,11 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"sharp": "^0.31.3",
|
"sharp": "^0.31.3",
|
||||||
|
"supabase": "^1.50.2",
|
||||||
"swr": "^2.1.2",
|
"swr": "^2.1.2",
|
||||||
"tailwind-merge": "^1.9.0",
|
"tailwind-merge": "^1.9.0",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "4.9.4"
|
"typescript": "4.9.4"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"supabase": "^1.50.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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'
|
import type {Config} from 'tailwindcss'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: ['./pages/**/*.{js,jsx,tsx}', './components/**/*.{js,jsx,tsx}'],
|
content: [
|
||||||
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./pages/**/*.{js,jsx,tsx}',
|
||||||
|
'./components/**/*.{js,jsx,tsx}'
|
||||||
|
],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/typography')
|
require('@tailwindcss/typography')
|
||||||
|
@ -23,12 +23,18 @@
|
|||||||
"@/*": [
|
"@/*": [
|
||||||
"*"
|
"*"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx"
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules"
|
||||||
|