Migrated to app router
134
app/about/page.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import React, {ElementType, ReactNode} from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import {Container} from '@/components/common/Container'
|
||||
import {MailIcon} from '@/components/icons/MailIcon'
|
||||
import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
|
||||
import clsx from 'clsx'
|
||||
import me from '@/public/images/me.jpg'
|
||||
import awsCCPBadge from '@/public/images/aws-certified-cloud-practitioner-badge.png'
|
||||
|
||||
export const metadata = {
|
||||
title: 'About - Ryan Freeman',
|
||||
description: 'I’m Ryan. I live in Dublin, Ireland where I work as a software engineer.'
|
||||
}
|
||||
|
||||
function SocialLink({
|
||||
className,
|
||||
href,
|
||||
children,
|
||||
icon: Icon
|
||||
}:
|
||||
{
|
||||
className: string,
|
||||
href: string,
|
||||
children: ReactNode,
|
||||
icon: ElementType
|
||||
}) {
|
||||
return (
|
||||
<li className={clsx(className, 'flex')}>
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex text-sm font-medium text-zinc-800 transition hover:text-indigo-500 dark:text-zinc-200 dark:hover:text-indigo-500"
|
||||
>
|
||||
<Icon className="h-6 w-6 flex-none fill-zinc-500 transition group-hover:fill-indigo-500"/>
|
||||
<span className="ml-4">{children}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function About() {
|
||||
return (
|
||||
<Container className="mt-16 sm:mt-32">
|
||||
<div className="grid grid-cols-1 gap-y-16 lg:grid-cols-2 lg:grid-rows-[auto_1fr] lg:gap-y-12">
|
||||
<div className="lg:pl-20">
|
||||
<div className="max-w-xs lg:max-w-sm px-2.5">
|
||||
<Image
|
||||
src={me}
|
||||
alt=""
|
||||
sizes="(min-width: 1024px) 32rem, 20rem"
|
||||
className="aspect-square shadow-inner rounded-2xl bg-zinc-100 object-cover dark:bg-zinc-800 rotate-3"
|
||||
placeholder="blur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:order-first lg:row-span-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-blue-400 to-emerald-400">
|
||||
I’m Ryan. I live in Dublin, Ireland where I work as a software engineer.
|
||||
</h1>
|
||||
<div className="mt-6 space-y-7 text-base text-zinc-600 dark:text-zinc-400">
|
||||
<p>
|
||||
I've always had an affinity for technology, and loved making things for as long as I can
|
||||
remember. My first computer was an Amstrad CPC 464 way back in the 90s, which is ancient by modern
|
||||
standards. My passion for tinkering continued through my teens and into adulthood where I
|
||||
eventually found my way into software engineering.
|
||||
</p>
|
||||
<p>
|
||||
In terms of my experience to date, I have a strong foundation in both front-end and back-end
|
||||
development. I enjoy working with React to create dynamic and responsive user interfaces,
|
||||
and I have a deep understanding of Java for building robust and scalable applications.
|
||||
Recently, I achieved one of my milestones which was to get AWS certified by the end of 2022.
|
||||
</p>
|
||||
<p>
|
||||
Currently, I work in the aviation industry for Aer Lingus as a software engineer where I work
|
||||
on exciting software projects for the airline. This includes everything from bug fixing, to
|
||||
working on legacy code and greenfield projects, to building customer-facing websites and services.
|
||||
I am responsible for ensuring that our software is of the highest quality, and that it meets the
|
||||
needs of our customers and stakeholders. The most fulfilling part of my job is knowing that the
|
||||
software I contribute to will be used by many thousands of people.
|
||||
</p>
|
||||
<p>
|
||||
In my free time, I enjoy staying up-to-date on the latest developments in the world of software
|
||||
engineering, and I am always looking for new ways to push the boundaries of what is possible with
|
||||
technology. I'm a huge advocate of free and open-source software and maintain a small
|
||||
Raspberry Pi server which I use to experiment with Docker containers for self-hosted
|
||||
services like Bitwarden, Nextcloud and Octoprint.
|
||||
</p>
|
||||
<p>
|
||||
On the hardware side, I build and maintain my own computers and I like to upgrade and modernise
|
||||
retro video game systems. When I'm not tinkering, I mostly spend time with my
|
||||
family and enjoy travelling whenever I can get away.
|
||||
</p>
|
||||
<p>
|
||||
That's me in a nutshell, thank you for visiting my website, I hope that you find the
|
||||
information here to be insightful. If you have any questions or would like to work with me, please
|
||||
don't hesitate to get in touch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:pl-20">
|
||||
<ul role="list">
|
||||
<SocialLink
|
||||
href="https://github.com/r-freeman"
|
||||
icon={GitHubIcon}
|
||||
className="mt-4">
|
||||
Follow on GitHub
|
||||
</SocialLink>
|
||||
<SocialLink
|
||||
href="https://linkedin.com/in/r-freeman/"
|
||||
icon={LinkedInIcon}
|
||||
className="mt-4">
|
||||
Follow on LinkedIn
|
||||
</SocialLink>
|
||||
<SocialLink
|
||||
href="mailto:hello@ryanfreeman.dev"
|
||||
icon={MailIcon}
|
||||
className="mt-8 border-t border-zinc-100 pt-8 dark:border-zinc-700/40">
|
||||
hello@ryanfreeman.dev
|
||||
</SocialLink>
|
||||
</ul>
|
||||
<Link href="https://credly.com/badges/10bd0eae-b383-411c-beb9-dadda80124c8/public_url">
|
||||
<Image
|
||||
src={awsCCPBadge}
|
||||
width="170"
|
||||
height="170"
|
||||
alt="AWS Certified Cloud Practitioner"
|
||||
className="mt-8"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
26
app/api/rpi/[slug]/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {NextResponse} from 'next/server'
|
||||
import {getRamUsage, getRootFsUsage, getSysLoad, getTemp, getUptime} from '@/lib/grafana'
|
||||
|
||||
export const fetchCache = 'force-no-store'
|
||||
|
||||
export async function GET(request: Request, {params}: { params: { slug: string } }) {
|
||||
const slug = params.slug
|
||||
let response
|
||||
if (slug === 'ram') {
|
||||
response = await getRamUsage()
|
||||
} else if (slug === 'rootfs') {
|
||||
response = await getRootFsUsage()
|
||||
} else if (slug === 'sysload') {
|
||||
response = await getSysLoad()
|
||||
} else if (slug === 'temp') {
|
||||
response = await getTemp()
|
||||
} else if (slug === 'uptime') {
|
||||
response = await getUptime()
|
||||
} else {
|
||||
return new Response('Not Found', {
|
||||
status: 404
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import {NextResponse} from 'next/server'
|
||||
import {getCurrentlyPlaying} from '@/lib/spotify'
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
|
||||
type Song = {
|
||||
item: {
|
||||
@ -24,24 +24,18 @@ type Song = {
|
||||
is_playing: boolean
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
export async function GET(request: Request) {
|
||||
const response = await getCurrentlyPlaying()
|
||||
|
||||
if (response.status === 204 || response.status > 400) {
|
||||
return res.status(200).json({
|
||||
isPlaying: false
|
||||
return new Response(JSON.stringify({isPlaying: false}), {
|
||||
status: 200
|
||||
})
|
||||
}
|
||||
|
||||
const song = await response.json() as Song
|
||||
const {item} = song
|
||||
|
||||
if (item === null) {
|
||||
return res.status(200).json({
|
||||
isPlaying: false
|
||||
})
|
||||
}
|
||||
|
||||
const artist = item.artists.map(artist => artist.name).join(', ')
|
||||
const title = item.name;
|
||||
const songUrl = item.external_urls.spotify
|
||||
@ -49,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const albumImageUrl = item.album.images[0].url
|
||||
const isPlaying = song.is_playing;
|
||||
|
||||
return res.status(200).json({
|
||||
return NextResponse.json({
|
||||
artist,
|
||||
title,
|
||||
songUrl,
|
@ -1,5 +1,5 @@
|
||||
import {NextResponse} from 'next/server'
|
||||
import {getRecentlyPlayed} from '@/lib/spotify'
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
|
||||
type Tracks = {
|
||||
items: [
|
||||
@ -28,19 +28,16 @@ type Tracks = {
|
||||
]
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
export async function GET(request: Request) {
|
||||
const response = await getRecentlyPlayed()
|
||||
|
||||
if (response.status > 400) {
|
||||
return res.status(200).json({})
|
||||
return new Response(JSON.stringify({status: response.statusText}), {
|
||||
status: response.status
|
||||
})
|
||||
}
|
||||
|
||||
const tracks = await response.json() as Tracks
|
||||
|
||||
if (tracks === null) {
|
||||
return res.status(200).json({})
|
||||
}
|
||||
|
||||
const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a)
|
||||
|
||||
const title = track.name;
|
||||
@ -49,7 +46,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const album = track.album.name
|
||||
const albumImageUrl = track.album.images[0].url
|
||||
|
||||
return res.status(200).json({
|
||||
return NextResponse.json({
|
||||
artist,
|
||||
title,
|
||||
songUrl,
|
41
app/api/views/[slug]/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {NextResponse} from 'next/server'
|
||||
import {cookies} from 'next/headers'
|
||||
import {createServerComponentClient} from '@supabase/auth-helpers-nextjs'
|
||||
import type {Database} from '@/types/database.types'
|
||||
|
||||
export async function GET(request: Request, {params}: { params: { slug: string } }) {
|
||||
if (typeof params.slug !== 'undefined') {
|
||||
try {
|
||||
const supabase = createServerComponentClient<Database>({cookies})
|
||||
const slug = params.slug.toString()
|
||||
const response = await supabase
|
||||
.from('analytics')
|
||||
.select('views')
|
||||
.eq('slug', slug)
|
||||
.returns<any>()
|
||||
|
||||
const {views} = response.data[0]
|
||||
if (typeof views !== 'undefined') {
|
||||
return NextResponse.json({views})
|
||||
}
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({status: 'Internal Server Error'}), {status: 500})
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({status: 'Not Found'}), {status: 404})
|
||||
}
|
||||
|
||||
export async function POST(request: Request, {params}: { params: { slug: string } }) {
|
||||
if (typeof params.slug !== 'undefined') {
|
||||
try {
|
||||
const supabase = createServerComponentClient<Database>({cookies})
|
||||
const slug = params.slug.toString()
|
||||
// @ts-ignore
|
||||
await supabase.rpc('increment_views', {page_slug: slug})
|
||||
return NextResponse.json({})
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({status: 'Internal Server Error'}), {status: 500})
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({status: 'Not Found'}), {status: 404})
|
||||
}
|
12
app/dashboard/loading.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import {metadata} from './page'
|
||||
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
|
||||
|
||||
export default function LoadingSkeleton() {
|
||||
return (
|
||||
<SimpleLayout heading="Dashboard."
|
||||
description={metadata.description}
|
||||
gradient="bg-gradient-to-r from-orange-300 to-rose-300">
|
||||
|
||||
</SimpleLayout>
|
||||
)
|
||||
}
|
88
app/dashboard/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
|
||||
import {Card} from '@/components/ui/Card'
|
||||
import {CardGroup} from '@/components/ui/CardGroup'
|
||||
import {getDashboardData} from '@/lib/dashboard'
|
||||
import {numberFormat} from '@/lib/numberFormat'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard - Ryan Freeman',
|
||||
description: 'This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus.'
|
||||
}
|
||||
|
||||
const config = {
|
||||
refreshInterval: 30000
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function Dashboard() {
|
||||
const metrics = await getDashboardData()
|
||||
// const tempData = (useSWR('api/grafana/temp', fetcher, config)).data as { temp: string }
|
||||
// const sysLoadData = (useSWR('api/grafana/sysload', fetcher, config)).data as { sysLoad: string }
|
||||
// const ramData = (useSWR('api/grafana/ram', fetcher, config)).data as { ramUsage: string }
|
||||
// const rootFsData = (useSWR('api/grafana/rootfs', fetcher, config)).data as { rootFsUsage: string }
|
||||
// const uptimeData = (useSWR('api/grafana/uptime', fetcher, config)).data as { days: number }
|
||||
|
||||
return (
|
||||
<SimpleLayout heading="Dashboard."
|
||||
description={metadata.description}
|
||||
gradient="bg-gradient-to-r from-orange-300 to-rose-300">
|
||||
{metrics.map(({groupName, groupItems}) => (
|
||||
<CardGroup title={groupName} key={groupName}>
|
||||
{groupItems.map((item) => (
|
||||
<Card as="li" key={item.title}>
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
|
||||
<Card.Link href={item.href}>{item.title}</Card.Link>
|
||||
</h2>
|
||||
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
|
||||
{typeof item.value === "number" ? numberFormat(item.value) : item.value}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
))}
|
||||
</CardGroup>
|
||||
))}
|
||||
{/*<CardGroup title="Raspberry Pi">*/}
|
||||
{/* <Card as="li">*/}
|
||||
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
|
||||
{/* <Card.Link href="#">Temperature</Card.Link>*/}
|
||||
{/* </h2>*/}
|
||||
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
|
||||
{/* {tempData ? `${tempData.temp}℃` : "—"}*/}
|
||||
{/* </Card.Description>*/}
|
||||
{/* </Card>*/}
|
||||
{/* <Card as="li">*/}
|
||||
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
|
||||
{/* <Card.Link href="#">Sys load (5m avg)</Card.Link>*/}
|
||||
{/* </h2>*/}
|
||||
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
|
||||
{/* {sysLoadData ? `${sysLoadData.sysLoad}%` : "—"}*/}
|
||||
{/* </Card.Description>*/}
|
||||
{/* </Card>*/}
|
||||
{/* <Card as="li">*/}
|
||||
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
|
||||
{/* <Card.Link href="#">RAM usage</Card.Link>*/}
|
||||
{/* </h2>*/}
|
||||
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
|
||||
{/* {ramData ? `${ramData.ramUsage}%` : "—"}*/}
|
||||
{/* </Card.Description>*/}
|
||||
{/* </Card>*/}
|
||||
{/* <Card as="li">*/}
|
||||
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
|
||||
{/* <Card.Link href="#">Root FS usage</Card.Link>*/}
|
||||
{/* </h2>*/}
|
||||
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
|
||||
{/* {rootFsData ? `${rootFsData.rootFsUsage}%` : "—"}*/}
|
||||
{/* </Card.Description>*/}
|
||||
{/* </Card>*/}
|
||||
{/* <Card as="li">*/}
|
||||
{/* <h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">*/}
|
||||
{/* <Card.Link href="#">Uptime days</Card.Link>*/}
|
||||
{/* </h2>*/}
|
||||
{/* <Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">*/}
|
||||
{/* {uptimeData ? `${numberFormat(uptimeData.days)}` : "—"}*/}
|
||||
{/* </Card.Description>*/}
|
||||
{/* </Card>*/}
|
||||
{/*</CardGroup>*/}
|
||||
</SimpleLayout>
|
||||
)
|
||||
}
|
BIN
app/favicon.ico
Normal file
After Width: | Height: | Size: 34 KiB |
34
app/layout.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import {ReactNode} from 'react'
|
||||
import {Providers} from '@/app/providers'
|
||||
import {Header} from '@/components/common/Header'
|
||||
import {Footer} from '@/components/common/Footer'
|
||||
|
||||
import '@/styles/tailwind.css'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Ryan Freeman - Full-stack software engineer from Dublin, Ireland.',
|
||||
description: 'Full-stack software engineer who enjoys building cloud-native applications.'
|
||||
}
|
||||
|
||||
export default function RootLayout({children}: { children: ReactNode }) {
|
||||
return (
|
||||
<html className="h-full antialiased" lang="en" suppressHydrationWarning={true}>
|
||||
<body className="flex h-full flex-col dark:bg-black-950">
|
||||
<div className="fixed inset-0 flex justify-center sm:px-8">
|
||||
<div className="flex w-full max-w-7xl lg:px-8">
|
||||
<div className="w-full bg-white dark:bg-black-950 dark:ring-zinc-300/20"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Providers>
|
||||
<Header/>
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
<Footer/>
|
||||
</Providers>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
@ -1,20 +1,13 @@
|
||||
import React from 'react'
|
||||
import Head from 'next/head'
|
||||
import {GetStaticProps} from 'next'
|
||||
import {Card} from '@/components/ui/Card'
|
||||
import {Resume} from '@/components/ui/Resume'
|
||||
import {Container} from '@/components/common/Container'
|
||||
import {
|
||||
GitHubIcon,
|
||||
LinkedInIcon
|
||||
} from '@/components/icons/SocialIcons'
|
||||
import {SocialLink} from '@/components/ui/SocialLink'
|
||||
import {Views} from '@/components/ui/Views'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
import {generateRssFeed} from '@/lib/generateRssFeed'
|
||||
import {generateSitemap} from '@/lib/generateSitemap'
|
||||
import {Resume} from '@/components/ui/Resume'
|
||||
import {SocialLink} from '@/components/ui/SocialLink'
|
||||
import {Container} from '@/components/common/Container'
|
||||
import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import {Article} from 'types'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
import type {Article} from '@/types'
|
||||
|
||||
function Article(article: Article) {
|
||||
return (
|
||||
@ -38,58 +31,13 @@ function Article(article: Article) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home({articles}: { articles: Article[] }) {
|
||||
export default async function Home() {
|
||||
const articles = (await getAllArticles())
|
||||
.slice(0, 3)
|
||||
.map(component => component)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
Ryan Freeman - Full-stack software engineer from Dublin, Ireland.
|
||||
</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Full-stack software engineer who enjoys building cloud-native applications."
|
||||
/>
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
|
||||
/>
|
||||
<meta
|
||||
property="og:type"
|
||||
content="website"
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Full-stack software engineer who enjoys building cloud-native applications."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="/static/images/photo-of-me-og.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:card"
|
||||
content="summary_large_image"/>
|
||||
<meta
|
||||
property="twitter:domain"
|
||||
content="ryanfreeman.dev"/>
|
||||
<meta
|
||||
property="twitter:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Full-stack software engineer who enjoys building cloud-native applications."/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="/static/images/photo-of-me-og.jpg"
|
||||
/>
|
||||
</Head>
|
||||
<Container className="mt-9">
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500">
|
||||
@ -135,19 +83,4 @@ export default function Home({articles}: { articles: Article[] }) {
|
||||
</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 {createSlug} from '@/lib/createSlug'
|
||||
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
|
||||
import {createSlug} from '../../../lib/createSlug'
|
||||
|
||||
export const meta = {
|
||||
author: 'Ryan Freeman',
|
||||
date: '2022-12-04',
|
||||
export const metadata = {
|
||||
authors: 'Ryan Freeman',
|
||||
title: 'A personal journey in software engineering',
|
||||
description: 'Hello there! If you\'re reading this, you\'ve likely stumbled upon my website — welcome! My name is Ryan Freeman, and I\'m a full-stack developer with a passion for creating intuitive and dynamic web applications.',
|
||||
date: '2022-12-04',
|
||||
description: 'Hello there! If you\'re reading this, you\'ve likely stumbled upon my website — welcome! My name is Ryan Freeman, and I\'m a full-stack developer with a passion for creating intuitive and dynamic web applications.'
|
||||
}
|
||||
|
||||
export default (props) => <ArticleLayout
|
||||
author={meta.author}
|
||||
date={meta.date}
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
slug={createSlug(meta.title)}
|
||||
title={metadata.title}
|
||||
date={metadata.date}
|
||||
description={metadata.description}
|
||||
slug={createSlug(metadata.title)}
|
||||
{...props} />
|
||||
|
||||
Hello there!
|
Before Width: | Height: | Size: 567 KiB After Width: | Height: | Size: 567 KiB |
@ -1,23 +1,23 @@
|
||||
import Image from 'next/image'
|
||||
import {ArticleLayout} from '@/components/layouts/ArticleLayout'
|
||||
import {createSlug} from '@/lib/createSlug'
|
||||
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
|
||||
import {createSlug} from '../../../lib/createSlug'
|
||||
import cargoShipImage from './ian-taylor-jOqJbvo1P9g-unsplash.jpg'
|
||||
|
||||
export const meta = {
|
||||
export const metadata = {
|
||||
author: 'Ryan Freeman',
|
||||
date: '2023-02-11',
|
||||
title: 'Docker cheat sheet',
|
||||
description: 'This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.',
|
||||
ogImage: '/static/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg'
|
||||
ogImage: '/images/ian-taylor-jOqJbvo1P9g-unsplash.jpg'
|
||||
}
|
||||
|
||||
export default (props) => <ArticleLayout
|
||||
author={meta.author}
|
||||
date={meta.date}
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
ogImage={meta.ogImage}
|
||||
slug={createSlug(meta.title)}
|
||||
author={metadata.author}
|
||||
date={metadata.date}
|
||||
title={metadata.title}
|
||||
description={metadata.description}
|
||||
ogImage={metadata.ogImage}
|
||||
slug={createSlug(metadata.title)}
|
||||
{...props} />
|
||||
|
||||
This is a living document of useful commands for maintaining and using Docker, and should function as a handy reference for developers and DevOps engineers.
|
@ -1,7 +1,7 @@
|
||||
import {ArticleLayout} from '@/components/layouts/ArticleLayout'
|
||||
import {createSlug} from '@/lib/createSlug'
|
||||
import {ArticleLayout} from '../../../components/layouts/ArticleLayout'
|
||||
import {createSlug} from '../../../lib/createSlug'
|
||||
|
||||
export const meta = {
|
||||
export const metadata = {
|
||||
author: 'Ryan Freeman',
|
||||
date: '2023-01-02',
|
||||
title: 'How to add TypeScript to an existing Next.js project',
|
||||
@ -9,11 +9,11 @@ export const meta = {
|
||||
}
|
||||
|
||||
export default (props) => <ArticleLayout
|
||||
author={meta.author}
|
||||
date={meta.date}
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
slug={createSlug(meta.title)}
|
||||
author={metadata.author}
|
||||
date={metadata.date}
|
||||
title={metadata.title}
|
||||
description={metadata.description}
|
||||
slug={createSlug(metadata.title)}
|
||||
{...props} />
|
||||
|
||||
# Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`.
|
54
app/writing/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
|
||||
import {Card} from '@/components/ui/Card'
|
||||
import {Views} from '@/components/ui/Views'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import type {Article} from '@/types'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Writing - Ryan Freeman',
|
||||
description: 'All of my long-form thoughts on software engineering, and more, displayed in chronological order.'
|
||||
}
|
||||
|
||||
function Article({article}: { article: Article }) {
|
||||
return (
|
||||
<article>
|
||||
<Card variant="inline">
|
||||
<Card.Title href={`/writing/${article.slug}`}>
|
||||
{article.title}
|
||||
</Card.Title>
|
||||
<p className="flex order-first space-x-1 z-10 mb-3 md:mb-0 md:ml-4 md:order-last flex-shrink-0">
|
||||
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
|
||||
{formatDate(article.date)}
|
||||
</Card.Eyebrow>
|
||||
<Views
|
||||
slug={article.slug}
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400"
|
||||
shouldUpdateViews={false}
|
||||
/>
|
||||
</p>
|
||||
</Card>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function Writing() {
|
||||
const articles = (await getAllArticles()).map(({component, ...meta}) => meta)
|
||||
|
||||
return (
|
||||
<SimpleLayout
|
||||
heading="Writing on software engineering, and everything in between."
|
||||
description={metadata.description}
|
||||
gradient="bg-gradient-to-r from-pink-500 to-violet-500"
|
||||
>
|
||||
<div className="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none">
|
||||
<div className="max-w-3xl space-y-16 mt-6">
|
||||
{articles.map((article) => (
|
||||
<Article key={article.slug} article={article}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SimpleLayout>
|
||||
)
|
||||
}
|
@ -1,47 +1,11 @@
|
||||
import {useRouter} from 'next/router'
|
||||
'use client'
|
||||
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {usePathname} from 'next/navigation'
|
||||
import {Container} from './Container'
|
||||
import {MobileNavigation, DesktopNavigation} from '@/components/ui/Navigation'
|
||||
import {ThemeButton} from '@/components/ui/ThemeButton'
|
||||
import {DesktopNavigation, MobileNavigation} from '@/components/ui/Navigation'
|
||||
import {Avatar, AvatarContainer} from '@/components/ui/Avatar'
|
||||
import {MoonIcon} from '@/components/icons/MoonIcon'
|
||||
import {SunIcon} from '@/components/icons/SunIcon'
|
||||
|
||||
function ModeToggle() {
|
||||
function disableTransitionsTemporarily() {
|
||||
document.documentElement.classList.add('[&_*]:!transition-none')
|
||||
window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('[&_*]:!transition-none')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
disableTransitionsTemporarily()
|
||||
|
||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
let isSystemDarkMode = darkModeMediaQuery.matches
|
||||
let isDarkMode = document.documentElement.classList.toggle('dark')
|
||||
|
||||
if (isDarkMode === isSystemDarkMode) {
|
||||
delete window.localStorage.isDarkMode
|
||||
} else {
|
||||
window.localStorage.isDarkMode = isDarkMode
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle dark mode"
|
||||
className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
|
||||
onClick={toggleMode}
|
||||
>
|
||||
<SunIcon
|
||||
className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-indigo-50 [@media(prefers-color-scheme:dark)]:stroke-indigo-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-indigo-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-indigo-600"/>
|
||||
<MoonIcon
|
||||
className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-indigo-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-indigo-500"/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(num: number, a: number, b: number) {
|
||||
let min = Math.min(a, b)
|
||||
@ -50,8 +14,8 @@ function clamp(num: number, a: number, b: number) {
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter()
|
||||
const isHomePage = router.pathname === '/'
|
||||
const pathname = usePathname()
|
||||
const isHomePage = pathname === '/'
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
const avatarRef = useRef<HTMLImageElement>(null)
|
||||
@ -227,7 +191,7 @@ export function Header() {
|
||||
</div>
|
||||
<div className="flex justify-end md:flex-1">
|
||||
<div className="pointer-events-auto">
|
||||
<ModeToggle/>
|
||||
<ThemeButton/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,4 @@
|
||||
import React, {ReactNode} from 'react'
|
||||
import * as process from 'process'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import {Container} from '@/components/common/Container'
|
||||
import {Prose} from '@/components/ui/Prose'
|
||||
@ -9,112 +7,66 @@ import {ArrowDownIcon} from '@/components/icons/ArrowDownIcon'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
|
||||
type ArticleLayout = {
|
||||
children?: ReactNode
|
||||
isRssFeed: boolean
|
||||
title: string
|
||||
description: string
|
||||
ogImage: string
|
||||
date: string
|
||||
description: string
|
||||
slug: string
|
||||
children?: ReactNode
|
||||
ogImage?: string
|
||||
isRssFeed?: boolean
|
||||
}
|
||||
|
||||
const gradients = [
|
||||
'bg-gradient-to-r from-blue-500 to-blue-600',
|
||||
'bg-[conic-gradient(at_left,_var(--tw-gradient-stops))] from-rose-500 to-indigo-700',
|
||||
'bg-[conic-gradient(at_left,_var(--tw-gradient-stops))] from-sky-400 to-blue-800',
|
||||
'bg-gradient-to-r from-orange-400 to-rose-400',
|
||||
'bg-gradient-to-r from-sky-400 to-blue-500'
|
||||
]
|
||||
|
||||
export function ArticleLayout({
|
||||
children,
|
||||
isRssFeed = false,
|
||||
title,
|
||||
description,
|
||||
ogImage,
|
||||
date,
|
||||
slug
|
||||
description,
|
||||
slug,
|
||||
children,
|
||||
ogImage,
|
||||
isRssFeed = false
|
||||
}: ArticleLayout) {
|
||||
if (isRssFeed) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{`${title} - Ryan Freeman`}</title>
|
||||
<meta name="description" content={description}/>
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}/writing/${slug}`}
|
||||
/>
|
||||
<meta
|
||||
property="og:type"
|
||||
content="website"
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={title}
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={description}
|
||||
/>
|
||||
{ogImage &&
|
||||
<>
|
||||
<meta
|
||||
property="og:image"
|
||||
content={ogImage}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:card"
|
||||
content="summary_large_image"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={ogImage}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
<meta
|
||||
property="twitter:domain"
|
||||
content="ryanfreeman.dev"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}/writing/${slug}`}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content={title}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content={description}
|
||||
/>
|
||||
</Head>
|
||||
<Container className="mt-16 lg:mt-32">
|
||||
<div className="xl:relative">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Link href="/writing" replace>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go back to articles"
|
||||
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:mb-0 lg:-mt-2 xl:-top-1.5 xl:left-0 xl:mt-0"
|
||||
>
|
||||
<ArrowDownIcon
|
||||
className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400 rotate-90"/>
|
||||
</button>
|
||||
</Link>
|
||||
<article>
|
||||
<header className="flex flex-col">
|
||||
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="order-first text-base text-zinc-500 dark:text-zinc-400">
|
||||
<time dateTime={date}>
|
||||
<span>{formatDate(date)}</span>
|
||||
</time>
|
||||
<Views slug={slug} shouldUpdateViews={true}/>
|
||||
</p>
|
||||
</header>
|
||||
<Prose className="mt-8">{children}</Prose>
|
||||
</article>
|
||||
</div>
|
||||
<Container className="mt-16 lg:mt-32">
|
||||
<div className="xl:relative">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Link href="/writing">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go back to articles"
|
||||
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:mb-0 lg:-mt-2 xl:-top-1.5 xl:left-0 xl:mt-0"
|
||||
>
|
||||
<ArrowDownIcon
|
||||
className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400 rotate-90"/>
|
||||
</button>
|
||||
</Link>
|
||||
<article>
|
||||
<header className="flex flex-col">
|
||||
<h1 className={`mt-6 text-4xl font-bold tracking-tight sm:text-5xl bg-clip-text dark:text-transparent ${gradients[Math.floor(gradients.length * Math.random())]}`}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="order-first text-base text-zinc-500 dark:text-zinc-400">
|
||||
<time dateTime={date}>
|
||||
<span>{formatDate(date)}</span>
|
||||
</time>
|
||||
<Views slug={slug} shouldUpdateViews={true}/>
|
||||
</p>
|
||||
</header>
|
||||
<Prose className="mt-8">{children}</Prose>
|
||||
</article>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
@ -2,19 +2,19 @@ import {ReactNode} from 'react'
|
||||
import {Container} from '@/components/common/Container'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
type SimpleLayout = {
|
||||
title: string
|
||||
intro: string
|
||||
export type SimpleLayoutProps = {
|
||||
heading: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
gradient: string
|
||||
}
|
||||
|
||||
export function SimpleLayout({
|
||||
title,
|
||||
intro,
|
||||
heading,
|
||||
description,
|
||||
children,
|
||||
gradient
|
||||
}: SimpleLayout) {
|
||||
}: SimpleLayoutProps) {
|
||||
return (
|
||||
<Container className="mt-16 sm:mt-32">
|
||||
<header className="max-w-2xl">
|
||||
@ -28,10 +28,10 @@ export function SimpleLayout({
|
||||
sm:text-5xl
|
||||
${gradient ? `${gradient} bg-clip-text dark:text-transparent` : ''}
|
||||
`)}>
|
||||
{title}
|
||||
{heading}
|
||||
</h1>
|
||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{intro}
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<div className="mt-16 sm:mt-20">{children}</div>
|
||||
|
@ -2,7 +2,7 @@ import {Props} from '@/types'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import avatar from '@/public/static/images/avatar.jpg'
|
||||
import me from '/public/images/me.jpg'
|
||||
|
||||
export function AvatarContainer({className, ...props}: { style?: Object } & Props) {
|
||||
return (
|
||||
@ -25,7 +25,7 @@ export function Avatar({large = false, className, ...props}: { large?: boolean,
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
src={avatar}
|
||||
src={me}
|
||||
alt=""
|
||||
sizes={large ? '4rem' : '2.25rem'}
|
||||
className={clsx(
|
||||
|
@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, {Fragment, ReactNode} from 'react'
|
||||
import {useRouter} from 'next/router'
|
||||
import {usePathname} from 'next/navigation'
|
||||
import {Popover, Transition} from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@ -77,8 +79,8 @@ export function MobileNavigation(props: Props) {
|
||||
}
|
||||
|
||||
function NavItem({href, children}: { href: string } & Props) {
|
||||
const router = useRouter()
|
||||
let isActive = router.pathname === href
|
||||
const pathname = usePathname()
|
||||
let isActive = pathname === href
|
||||
|
||||
return (
|
||||
<li>
|
||||
|
@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import useSWR from 'swr'
|
||||
import fetcher from '@/lib/fetcher'
|
||||
import Image from 'next/image'
|
||||
|
62
components/ui/ThemeButton.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useTheme} from 'next-themes'
|
||||
import {SunIcon} from '@/components/icons/SunIcon'
|
||||
import {MoonIcon} from '@/components/icons/MoonIcon'
|
||||
|
||||
export function ThemeButton() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const {theme, setTheme} = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setMounted(true), 500)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [])
|
||||
|
||||
function disableTransitionsTemporarily() {
|
||||
document.documentElement.classList.add('[&_*]:!transition-none')
|
||||
window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('[&_*]:!transition-none')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
disableTransitionsTemporarily()
|
||||
|
||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
let isSystemDarkMode = darkModeMediaQuery.matches
|
||||
let isDarkMode = theme === 'dark'
|
||||
|
||||
if (isDarkMode === isSystemDarkMode) {
|
||||
setTheme('light')
|
||||
} else {
|
||||
setTheme('dark')
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return <ThemeButton.Skeleton/>
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle dark mode"
|
||||
className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
|
||||
onClick={() => toggleTheme()}
|
||||
>
|
||||
<SunIcon
|
||||
className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-indigo-50 [@media(prefers-color-scheme:dark)]:stroke-indigo-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-indigo-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-indigo-600"/>
|
||||
<MoonIcon
|
||||
className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-indigo-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-indigo-500"/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
ThemeButton.Skeleton = function ThemeButtonSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="animate-pulse rounded-full bg-zinc-100 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20">
|
||||
<div className="h-6 w-6"/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
import {useSupabaseClient} from '@supabase/auth-helpers-react'
|
||||
'use client'
|
||||
|
||||
import {createPagesBrowserClient} from '@supabase/auth-helpers-nextjs'
|
||||
import {ElementType, useEffect} from 'react'
|
||||
import useSWR, {useSWRConfig} from 'swr'
|
||||
import fetcher from '@/lib/fetcher'
|
||||
@ -12,15 +14,16 @@ type ViewsProps = {
|
||||
shouldRender?: boolean
|
||||
}
|
||||
|
||||
const supabase = createPagesBrowserClient()
|
||||
|
||||
export function Views({as: Component = 'span', slug, className, shouldUpdateViews = true, shouldRender = true}: ViewsProps) {
|
||||
const supabaseClient = useSupabaseClient()
|
||||
const {data} = useSWR(`/api/views/${slug}`, fetcher) as { data: { views: number } }
|
||||
const {mutate} = useSWRConfig()
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUpdateViews) {
|
||||
// subscribe to analytics table and react to updates at row level
|
||||
const sub = supabaseClient
|
||||
const sub = supabase
|
||||
.channel('any')
|
||||
.on('postgres_changes', {
|
||||
event: 'UPDATE',
|
||||
|
@ -1,20 +1,14 @@
|
||||
import {GetServerSidePropsContext} from 'next'
|
||||
import {createServerSupabaseClient} from '@supabase/auth-helpers-nextjs'
|
||||
import {
|
||||
getTopRepo,
|
||||
getTotalFollowers,
|
||||
getTotalForks,
|
||||
getTotalRepos,
|
||||
getTotalStars
|
||||
} from '@/lib/github'
|
||||
import {cookies} from 'next/headers'
|
||||
import {createServerComponentClient} from '@supabase/auth-helpers-nextjs'
|
||||
import {getTopRepo, getTotalFollowers, getTotalForks, getTotalRepos, getTotalStars} from '@/lib/github'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import {getTopArtist, getTopGenre} from '@/lib/spotify'
|
||||
import {getStats} from '@/lib/statsfm'
|
||||
import {Metric} from '@/types'
|
||||
import {getStats} from "@/lib/statsfm";
|
||||
|
||||
export async function getDashboardData(context: GetServerSidePropsContext) {
|
||||
const supabaseClient = createServerSupabaseClient(context)
|
||||
const {data: views} = await supabaseClient.rpc('total_views')
|
||||
export async function getDashboardData() {
|
||||
const supabase = createServerComponentClient({cookies})
|
||||
const {data: views} = await supabase.rpc('total_views')
|
||||
const [totalRepos, totalFollowers] = await Promise.all([
|
||||
getTotalRepos(),
|
||||
getTotalFollowers()
|
||||
@ -26,27 +20,27 @@ export async function getDashboardData(context: GetServerSidePropsContext) {
|
||||
const totalArticles = (await getAllArticles()).length
|
||||
const topArtist = await getTopArtist()
|
||||
const {genre} = await getTopGenre()
|
||||
// const {hoursListened, minutesListened, streams} = await getStats()
|
||||
const {hoursListened, minutesListened, streams} = await getStats()
|
||||
|
||||
const metrics: Metric[] = [
|
||||
// {
|
||||
// title: "Streams",
|
||||
// value: +streams,
|
||||
// group: "Spotify",
|
||||
// href: "https://open.spotify.com/?"
|
||||
// },
|
||||
// {
|
||||
// title: "Hours listened",
|
||||
// value: +hoursListened,
|
||||
// group: "Spotify",
|
||||
// href: "https://open.spotify.com/?"
|
||||
// },
|
||||
// {
|
||||
// title: "Minutes listened",
|
||||
// value: +minutesListened,
|
||||
// group: "Spotify",
|
||||
// href: "https://open.spotify.com/?"
|
||||
// },
|
||||
{
|
||||
title: "Streams",
|
||||
value: +streams,
|
||||
group: "Spotify",
|
||||
href: "https://open.spotify.com/?"
|
||||
},
|
||||
{
|
||||
title: "Hours listened",
|
||||
value: +hoursListened,
|
||||
group: "Spotify",
|
||||
href: "https://open.spotify.com/?"
|
||||
},
|
||||
{
|
||||
title: "Minutes listened",
|
||||
value: +minutesListened,
|
||||
group: "Spotify",
|
||||
href: "https://open.spotify.com/?"
|
||||
},
|
||||
{
|
||||
title: "Top genre",
|
||||
value: genre,
|
||||
|
@ -18,8 +18,8 @@ export async function generateRssFeed() {
|
||||
author,
|
||||
id: siteUrl!,
|
||||
link: siteUrl,
|
||||
image: `${siteUrl}/static/icons/favicon.ico`,
|
||||
favicon: `${siteUrl}/static/icons/favicon.ico`,
|
||||
image: `${siteUrl}/favicon.ico`,
|
||||
favicon: `${siteUrl}/favicon.ico`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}`,
|
||||
feedLinks: {
|
||||
rss2: `${siteUrl}/rss/feed.xml`,
|
||||
|
@ -2,19 +2,19 @@ import glob from 'fast-glob'
|
||||
import * as path from 'path'
|
||||
|
||||
async function importArticle(articleFilename: string) {
|
||||
let {meta, default: component} = await import(
|
||||
`/pages/writing/${articleFilename}`
|
||||
let {metadata, default: component} = await import(
|
||||
`/app/writing/${articleFilename}`
|
||||
)
|
||||
return {
|
||||
slug: articleFilename.replace(/(\/index)?\.mdx$/, ''),
|
||||
...meta,
|
||||
slug: articleFilename.replace(/(\/page)?\.mdx$/, ''),
|
||||
...metadata,
|
||||
component,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllArticles() {
|
||||
let articleFilenames = await glob(['*.mdx', '*/index.mdx'], {
|
||||
cwd: path.join(process.cwd(), './pages/writing'),
|
||||
let articleFilenames = await glob(['*.mdx', '*/page.mdx'], {
|
||||
cwd: path.join(process.cwd(), './app/writing'),
|
||||
})
|
||||
|
||||
let articles = await Promise.all(articleFilenames.map(importArticle))
|
||||
|
15
mdx-components.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
|
||||
// This file allows you to provide custom React components
|
||||
// to be used in MDX files. You can import and use any
|
||||
// React component you want, including components from
|
||||
// other libraries.
|
||||
|
||||
// This file is required to use MDX in `app` directory.
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
// Allows customizing built-in components, e.g. to add styling.
|
||||
// h1: ({ children }) => <h1 style={{ fontSize: "100px" }}>{children}</h1>,
|
||||
...components,
|
||||
}
|
||||
}
|
11
middleware.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import {createMiddlewareClient} from '@supabase/auth-helpers-nextjs'
|
||||
import type {NextRequest} from 'next/server'
|
||||
import {NextResponse} from 'next/server'
|
||||
import type {Database} from '@/types/database.types'
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const res = NextResponse.next()
|
||||
const supabase = createMiddlewareClient<Database>({req, res})
|
||||
await supabase.auth.getSession()
|
||||
return res
|
||||
}
|
888
package-lock.json
generated
19
package.json
@ -12,25 +12,28 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.7",
|
||||
"@mapbox/rehype-prism": "^0.7.0",
|
||||
"@next/mdx": "^13.1.1",
|
||||
"@supabase/auth-helpers-nextjs": "^0.6.0",
|
||||
"@supabase/auth-helpers-react": "^0.3.1",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "^13.4.10",
|
||||
"@supabase/auth-helpers-nextjs": "^0.7.3",
|
||||
"@supabase/supabase-js": "^2.27.0",
|
||||
"@tailwindcss/typography": "^0.5.8",
|
||||
"@types/mdx": "^2.0.3",
|
||||
"@types/mdx": "^2.0.5",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"clsx": "^1.2.1",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "^13.3.0",
|
||||
"fast-glob": "^3.2.12",
|
||||
"feed": "^4.2.2",
|
||||
"focus-visible": "^5.2.0",
|
||||
"motion": "^10.15.5",
|
||||
"next": "^13.3.0",
|
||||
"node-fetch": "^3.3.0",
|
||||
"next": "^13.4.10",
|
||||
"next-themes": "^0.2.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-focus-visible": "^7.1.0",
|
||||
@ -38,13 +41,11 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.31.3",
|
||||
"supabase": "^1.50.2",
|
||||
"swr": "^2.1.2",
|
||||
"tailwind-merge": "^1.9.0",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supabase": "^1.50.2"
|
||||
}
|
||||
}
|
||||
|
@ -1,50 +0,0 @@
|
||||
import type {AppProps} from 'next/app'
|
||||
import {useRouter} from 'next/router'
|
||||
import NProgress from 'nprogress'
|
||||
import {createBrowserSupabaseClient} from '@supabase/auth-helpers-nextjs'
|
||||
import {SessionContextProvider, Session} from '@supabase/auth-helpers-react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Header} from '@/components/common/Header'
|
||||
import {Footer} from '@/components/common/Footer'
|
||||
|
||||
import '../styles/nprogress.css'
|
||||
import '../styles/tailwind.css'
|
||||
import 'focus-visible'
|
||||
|
||||
export default function App({Component, pageProps}: AppProps<{ initialSession: Session }>) {
|
||||
const [supabaseClient] = useState(() => createBrowserSupabaseClient())
|
||||
const router = useRouter()
|
||||
NProgress.configure({showSpinner: false})
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteStart = () => NProgress.start()
|
||||
const handleRouteDone = () => NProgress.done()
|
||||
|
||||
router.events.on("routeChangeStart", handleRouteStart)
|
||||
router.events.on("routeChangeComplete", handleRouteDone)
|
||||
router.events.on("routeChangeError", handleRouteDone)
|
||||
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", handleRouteStart)
|
||||
router.events.off("routeChangeComplete", handleRouteDone)
|
||||
router.events.off("routeChangeError", handleRouteDone)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionContextProvider supabaseClient={supabaseClient} initialSession={pageProps.initialSession}>
|
||||
<div className="fixed inset-0 flex justify-center sm:px-8">
|
||||
<div className="flex w-full max-w-7xl lg:px-8">
|
||||
<div className="w-full bg-white dark:bg-black-950 dark:ring-zinc-300/20"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Header/>
|
||||
<main>
|
||||
<Component {...pageProps} />
|
||||
</main>
|
||||
<Footer/>
|
||||
</div>
|
||||
</SessionContextProvider>
|
||||
)
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
import {Html, Head, Main, NextScript} from 'next/document'
|
||||
|
||||
const modeScript = `
|
||||
darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
updateMode()
|
||||
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
|
||||
window.addEventListener('storage', updateModeWithoutTransitions)
|
||||
|
||||
function updateMode() {
|
||||
let isSystemDarkMode = darkModeMediaQuery.matches
|
||||
let isDarkMode = window.localStorage.isDarkMode === 'true' || (!('isDarkMode' in window.localStorage) && isSystemDarkMode)
|
||||
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
if (isDarkMode === isSystemDarkMode) {
|
||||
delete window.localStorage.isDarkMode
|
||||
}
|
||||
}
|
||||
|
||||
function disableTransitionsTemporarily() {
|
||||
document.documentElement.classList.add('[&_*]:!transition-none')
|
||||
window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('[&_*]:!transition-none')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function updateModeWithoutTransitions() {
|
||||
disableTransitionsTemporarily()
|
||||
updateMode()
|
||||
}
|
||||
`
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html className="h-full antialiased" lang="en">
|
||||
<Head>
|
||||
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
href="/rss/feed.xml"
|
||||
title="RSS feed for ryanfreeman.dev"
|
||||
/>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/feed+json"
|
||||
href="/rss/feed.json"
|
||||
title="RSS feed for ryanfreeman.dev"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/static/icons/favicon-16x16.png"
|
||||
sizes="16x16"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/static/icons/favicon-32x32.png"
|
||||
sizes="32x32"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/static/icons/apple-touch-icon.png"
|
||||
/>
|
||||
</Head>
|
||||
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
|
||||
<body className="flex h-full flex-col dark:bg-black-950">
|
||||
<Main/>
|
||||
<NextScript/>
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
146
pages/about.tsx
@ -1,146 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import {ElementType, ReactNode} from 'react'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import {Container} from '@/components/common/Container'
|
||||
import {
|
||||
GitHubIcon,
|
||||
LinkedInIcon
|
||||
} from '@/components/icons/SocialIcons'
|
||||
import {MailIcon} from '@/components/icons/MailIcon'
|
||||
import photoOfMe from '@/public/static/images/photo-of-me.jpg'
|
||||
import awsCCPBadge from '@/public/static/images/aws-certified-cloud-practitioner-badge.png'
|
||||
|
||||
function SocialLink({
|
||||
className,
|
||||
href,
|
||||
children,
|
||||
icon: Icon
|
||||
}:
|
||||
{
|
||||
className: string,
|
||||
href: string,
|
||||
children: ReactNode,
|
||||
icon: ElementType
|
||||
}) {
|
||||
return (
|
||||
<li className={clsx(className, 'flex')}>
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex text-sm font-medium text-zinc-800 transition hover:text-indigo-500 dark:text-zinc-200 dark:hover:text-indigo-500"
|
||||
>
|
||||
<Icon className="h-6 w-6 flex-none fill-zinc-500 transition group-hover:fill-indigo-500"/>
|
||||
<span className="ml-4">{children}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>About - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="I’m Ryan. I live in Dublin, Ireland where I work as a software engineer."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="About - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="I’m Ryan. I live in Dublin, Ireland where I work as a software engineer."
|
||||
/>
|
||||
</Head>
|
||||
<Container className="mt-16 sm:mt-32">
|
||||
<div className="grid grid-cols-1 gap-y-16 lg:grid-cols-2 lg:grid-rows-[auto_1fr] lg:gap-y-12">
|
||||
<div className="lg:pl-20">
|
||||
<div className="max-w-xs px-2.5 lg:max-w-none">
|
||||
<Image
|
||||
src={photoOfMe}
|
||||
alt=""
|
||||
sizes="(min-width: 1024px) 32rem, 20rem"
|
||||
className="aspect-square shadow-inner rounded-2xl bg-zinc-100 object-cover dark:bg-zinc-800 rotate-3"
|
||||
placeholder="blur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:order-first lg:row-span-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-blue-400 to-emerald-400">
|
||||
I’m Ryan. I live in Dublin, Ireland where I work as a software engineer.
|
||||
</h1>
|
||||
<div className="mt-6 space-y-7 text-base text-zinc-600 dark:text-zinc-400">
|
||||
<p>
|
||||
I've always had an affinity for technology, and loved making things for as long as I can
|
||||
remember. My first computer was an Amstrad CPC 464 way back in the 90s, which is ancient by modern
|
||||
standards. My passion for tinkering continued through my teens and into adulthood where I
|
||||
eventually found my way into software engineering.
|
||||
</p>
|
||||
<p>
|
||||
In terms of my experience to date, I have a strong foundation in both front-end and back-end
|
||||
development. I enjoy working with React to create dynamic and response user interfaces,
|
||||
and I have a deep understanding of Java for building powerful and scalable applications.
|
||||
Recently, I achieved one of my milestones which was to get AWS certified by the end of 2022.
|
||||
</p>
|
||||
<p>
|
||||
Currently, I work in the aviation industry for Aer Lingus as a software engineer where I work
|
||||
on exciting software projects for the airline. This includes everything from bug fixing, to
|
||||
working on legacy code and greenfield projects, to building customer-facing websites and services.
|
||||
I am responsible for ensuring that our software is of the highest quality, and that it meets the
|
||||
needs of our customers and stakeholders. The most fulfilling part of my job is knowing that the
|
||||
software I contribute to will be used by many thousands of people.
|
||||
</p>
|
||||
<p>
|
||||
In my free time, I enjoy staying up-to-date on the latest developments in the world of software
|
||||
engineering, and I am always looking for new ways to push the boundaries of what is possible with
|
||||
technology. I'm a huge advocate of free and open-source software and maintain a small
|
||||
Raspberry Pi server which I use to experiment with Docker containers for self-hosted
|
||||
services like Bitwarden, Nextcloud and Octoprint.
|
||||
</p>
|
||||
<p>
|
||||
On the hardware side, I build and maintain my own computers and I like to upgrade and modernise
|
||||
retro video game systems. When I'm not tinkering, I mostly spend time with my
|
||||
family and enjoy travelling whenever I can get away.
|
||||
</p>
|
||||
<p>
|
||||
That's me in a nutshell, thank you for visiting my website, I hope that you find the
|
||||
information here to be insightful. If you have any questions or would like to work with me, please
|
||||
don't hesitate to get in touch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:pl-20">
|
||||
<ul role="list">
|
||||
<SocialLink href="https://github.com/r-freeman" icon={GitHubIcon} className="mt-4">
|
||||
Follow on GitHub
|
||||
</SocialLink>
|
||||
<SocialLink href="https://linkedin.com/in/r-freeman/" icon={LinkedInIcon} className="mt-4">
|
||||
Follow on LinkedIn
|
||||
</SocialLink>
|
||||
<SocialLink
|
||||
href="mailto:hello@ryanfreeman.dev"
|
||||
icon={MailIcon}
|
||||
className="mt-8 border-t border-zinc-100 pt-8 dark:border-zinc-700/40"
|
||||
>
|
||||
hello@ryanfreeman.dev
|
||||
</SocialLink>
|
||||
</ul>
|
||||
<Link href="https://credly.com/badges/10bd0eae-b383-411c-beb9-dadda80124c8/public_url">
|
||||
<Image
|
||||
src={awsCCPBadge}
|
||||
width="170"
|
||||
height="170"
|
||||
alt="AWS Certified Cloud Practitioner"
|
||||
className="mt-8"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
import {getRamUsage} from '@/lib/grafana'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const response = await getRamUsage()
|
||||
|
||||
return res.status(200).json(response)
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
import {getRootFsUsage} from '@/lib/grafana'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const response = await getRootFsUsage()
|
||||
|
||||
return res.status(200).json(response)
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
import {getSysLoad} from '@/lib/grafana'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const response = await getSysLoad()
|
||||
|
||||
return res.status(200).json(response)
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
import {getTemp} from '@/lib/grafana'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const response = await getTemp()
|
||||
|
||||
return res.status(200).json(response)
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
import {getUptime} from '@/lib/grafana'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const response = await getUptime()
|
||||
|
||||
return res.status(200).json(response)
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import {createServerSupabaseClient} from '@supabase/auth-helpers-nextjs'
|
||||
import {NextApiRequest, NextApiResponse} from 'next'
|
||||
import {Database} from '@/types/database.types'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const supabaseServerClient = createServerSupabaseClient<Database>({
|
||||
req,
|
||||
res,
|
||||
})
|
||||
|
||||
if (req.method === 'POST') {
|
||||
if (req.query.slug !== undefined) {
|
||||
const slug: string = req.query.slug.toString()
|
||||
// @ts-ignore
|
||||
await supabaseServerClient.rpc('increment_views', {page_slug: slug})
|
||||
|
||||
return res.status(200).json({})
|
||||
}
|
||||
|
||||
return res.status(400).json({})
|
||||
} else if (req.method === 'GET') {
|
||||
if (req.query.slug !== undefined) {
|
||||
const slug: string = req.query.slug.toString()
|
||||
const response = await supabaseServerClient
|
||||
.from('analytics')
|
||||
.select('views')
|
||||
.eq('slug', slug)
|
||||
.returns<any>()
|
||||
|
||||
const {views} = response.data[0]
|
||||
|
||||
return res.status(200).json({views})
|
||||
}
|
||||
|
||||
return res.status(400).json({})
|
||||
}
|
||||
|
||||
return res.status(405).json({})
|
||||
}
|
@ -1,113 +0,0 @@
|
||||
import React from 'react'
|
||||
import Head from 'next/head'
|
||||
import {GetServerSideProps} from 'next'
|
||||
import useSWR from 'swr'
|
||||
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
|
||||
import {Card} from '@/components/ui/Card'
|
||||
import {CardGroup} from '@/components/ui/CardGroup'
|
||||
import {numberFormat} from '@/lib/numberFormat'
|
||||
import {getDashboardData} from '@/lib/dashboard'
|
||||
import fetcher from '@/lib/fetcher'
|
||||
import type {MetricGroup} from '@/types'
|
||||
|
||||
const config = {
|
||||
refreshInterval: 30000
|
||||
}
|
||||
|
||||
export default function Dashboard({metrics}: { metrics: MetricGroup }) {
|
||||
const tempData = (useSWR('api/grafana/temp', fetcher, config)).data as { temp: string }
|
||||
const sysLoadData = (useSWR('api/grafana/sysload', fetcher, config)).data as { sysLoad: string }
|
||||
const ramData = (useSWR('api/grafana/ram', fetcher, config)).data as { ramUsage: string }
|
||||
const rootFsData = (useSWR('api/grafana/rootfs', fetcher, config)).data as { rootFsUsage: string }
|
||||
const uptimeData = (useSWR('api/grafana/uptime', fetcher, config)).data as { days: number }
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Dashboard - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Dashboard - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus."
|
||||
/>
|
||||
</Head>
|
||||
<SimpleLayout
|
||||
title="Dashboard."
|
||||
intro="This is my digital life in numbers, I use this dashboard to keep track of various metrics across platforms like Spotify, GitHub, Twitter and for monitoring the performance of my Raspberry Pi using Grafana and Prometheus."
|
||||
gradient="bg-gradient-to-r from-orange-300 to-rose-300"
|
||||
>
|
||||
{metrics.map(({groupName, groupItems}) => (
|
||||
<CardGroup title={groupName} key={groupName}>
|
||||
{groupItems.map((item) => (
|
||||
<Card as="li" key={item.title}>
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
|
||||
<Card.Link href={item.href}>{item.title}</Card.Link>
|
||||
</h2>
|
||||
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
|
||||
{typeof item.value === "number" ? numberFormat(item.value) : item.value}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
))}
|
||||
</CardGroup>
|
||||
))}
|
||||
<CardGroup title="Raspberry Pi">
|
||||
<Card as="li">
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
|
||||
<Card.Link href="#">Temperature</Card.Link>
|
||||
</h2>
|
||||
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
|
||||
{tempData ? `${tempData.temp}℃` : "—"}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<Card as="li">
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
|
||||
<Card.Link href="#">Sys load (5m avg)</Card.Link>
|
||||
</h2>
|
||||
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
|
||||
{sysLoadData ? `${sysLoadData.sysLoad}%` : "—"}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<Card as="li">
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
|
||||
<Card.Link href="#">RAM usage</Card.Link>
|
||||
</h2>
|
||||
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
|
||||
{ramData ? `${ramData.ramUsage}%` : "—"}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<Card as="li">
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
|
||||
<Card.Link href="#">Root FS usage</Card.Link>
|
||||
</h2>
|
||||
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
|
||||
{rootFsData ? `${rootFsData.rootFsUsage}%` : "—"}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<Card as="li">
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-400">
|
||||
<Card.Link href="#">Uptime days</Card.Link>
|
||||
</h2>
|
||||
<Card.Description className="mt-0 text-zinc-800 dark:text-zinc-100 font-semibold text-3xl">
|
||||
{uptimeData ? `${numberFormat(uptimeData.days)}` : "—"}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
</CardGroup>
|
||||
</SimpleLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
return {
|
||||
props: {
|
||||
metrics: await getDashboardData(context)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
import {GetStaticProps} from 'next'
|
||||
import Head from 'next/head'
|
||||
import {Card} from '@/components/ui/Card'
|
||||
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
|
||||
import {SparklesIcon} from '@/components/icons/SparklesIcon'
|
||||
import {ShareIcon} from '@/components/icons/ShareIcon'
|
||||
import {getPinnedRepos} from '@/lib/github'
|
||||
import {numberFormat} from '@/lib/numberFormat'
|
||||
import type {Repo} from '@/types'
|
||||
|
||||
export default function Projects({pinnedRepos}: { pinnedRepos: Repo[] }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Projects - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Things I've made and projects I've worked on."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Projects - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Things I've made and projects I've worked on."
|
||||
/>
|
||||
</Head>
|
||||
<SimpleLayout
|
||||
title="Things I've made and projects I've worked on."
|
||||
intro="Here's a selection of academic and personal projects that I have worked on. Many of them are open-source, so if you see something that piques your interest, check out the code and contribute if you have ideas for how it can be improved."
|
||||
gradient="bg-gradient-to-r from-sky-400 to-blue-500"
|
||||
>
|
||||
<ul
|
||||
role="list"
|
||||
className="grid grid-cols-1 gap-x-12 gap-y-16 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{pinnedRepos.map((repo) => (
|
||||
<Card as="li" key={repo.name}>
|
||||
<h2 className="text-base font-semibold transition group-hover:text-indigo-500 text-zinc-800 dark:text-zinc-100">
|
||||
<Card.Link href={repo.url}>{repo.name}</Card.Link>
|
||||
</h2>
|
||||
<Card.Description>{repo.description}</Card.Description>
|
||||
<div
|
||||
className="z-10 flex space-x-16 sm:space-x-0 sm:justify-between mt-8 items-center w-full group text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<p
|
||||
className="flex items-center">
|
||||
<span>{repo.primaryLanguage.name}</span>
|
||||
<span
|
||||
className="mr-2 w-4 h-4 rounded-full order-first"
|
||||
style={{backgroundColor: repo.primaryLanguage.color}}/>
|
||||
</p>
|
||||
<div className="flex space-x-6">
|
||||
<p className="flex items-center">
|
||||
{numberFormat(repo.stargazerCount)}
|
||||
<SparklesIcon className="order-first mr-2 w-5 h-5 fill-zinc-400 dark:fill-zinc-500"/>
|
||||
</p>
|
||||
<p className="flex items-center">
|
||||
{numberFormat(repo.forkCount)}
|
||||
<ShareIcon
|
||||
className="order-first mr-2 w-5 h-5 fill-zinc-400 dark:fill-zinc-500 -rotate-90"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
</SimpleLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
return {
|
||||
props: {
|
||||
pinnedRepos: (await getPinnedRepos())
|
||||
.sort((a, b) => b.stargazerCount - a.stargazerCount)
|
||||
}
|
||||
}
|
||||
}
|
122
pages/uses.tsx
@ -1,122 +0,0 @@
|
||||
import Head from 'next/head'
|
||||
|
||||
import {Card} from '@/components/ui/Card'
|
||||
import {Section} from '@/components/ui/Section'
|
||||
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
function ToolsSection({children, title}: { children: ReactNode, title: string }) {
|
||||
return (
|
||||
<Section title={title}>
|
||||
<ul role="list" className="space-y-16">
|
||||
{children}
|
||||
</ul>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function Tool({title, href, children}: { title: string, href: string, children: ReactNode }) {
|
||||
return (
|
||||
<Card as="li">
|
||||
<Card.Title as="h3" href={href}>
|
||||
{title}
|
||||
</Card.Title>
|
||||
<Card.Description>{children}</Card.Description>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Uses() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Uses - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Software I use, equipment that makes my job easier, and other things I recommend."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Uses - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Software I use, equipment that makes my job easier, and other things I recommend."
|
||||
/>
|
||||
</Head>
|
||||
<SimpleLayout
|
||||
title="Software I use, equipment that makes my job easier, and other things I recommend."
|
||||
intro="I get asked a lot about the things I use to build software and stay productive. Here’s a big list of all of my favourite gear."
|
||||
gradient="bg-gradient-to-r from-orange-400 to-rose-400"
|
||||
>
|
||||
<div className="space-y-20">
|
||||
<ToolsSection title="Workstation">
|
||||
<Tool title="PC Build 2022" href="https://pcpartpicker.com/b/KXfPxr">
|
||||
This is my main Intel-based computer which I built in 2022. I've recently added more storage and
|
||||
a new monitor. Click here to see a comprehensive listing of all the parts used in this build on
|
||||
PCPartPicker.
|
||||
</Tool>
|
||||
<Tool title="Herman Miller Aeron Chair"
|
||||
href="https://hermanmiller.com/en_eur/products/seating/office-chairs/aeron-chairs/">
|
||||
I bought this chair second-hand when I started working for Apple, it's extremely comfortable and
|
||||
ergonomic for those long hours spent at the desk.
|
||||
</Tool>
|
||||
</ToolsSection>
|
||||
<ToolsSection title="Development tools">
|
||||
<Tool title="JetBrains" href="https://jetbrains.com/">
|
||||
I use a mix of JetBrain apps for my IDEs depending on what I'm working on. For JavaScript
|
||||
projects, I use WebStorm. PyCharm for python and IntelliJ IDEA Ultimate for Java. I use the same
|
||||
keyboard shortcuts across these apps which is great for productivity.
|
||||
</Tool>
|
||||
<Tool title="Insomnia" href="https://insomnia.rest/">
|
||||
Good tool for designing and testing REST APIs. I used to use Postman but I found the interface too
|
||||
cluttered and prefer the simplicity of Insomnia.
|
||||
</Tool>
|
||||
</ToolsSection>
|
||||
<ToolsSection title="Productivity">
|
||||
<Tool title="RegionToShare" href="https://github.com/tom-englert/RegionToShare">
|
||||
Great app for Windows which allows you share a region of the screen, handy for single monitor
|
||||
set ups such as ultrawides when you don't want to share the entire screen.
|
||||
</Tool>
|
||||
</ToolsSection>
|
||||
<ToolsSection title="Design">
|
||||
<Tool title="Balsamiq Wireframes" href="https://balsamiq.com/">
|
||||
I use this software for creating low-fidelity wireframes and interfaces. It's great for
|
||||
experimenting with ideas.
|
||||
</Tool>
|
||||
<Tool title="Color Picker" href="https://learn.microsoft.com/en-us/windows/powertoys/color-picker">
|
||||
Color Picker is included in the PowerToys set of enhancements for Windows. Using the eye dropper you
|
||||
can easily identify colours on the screen and copy the colour's code to your clipboard for use
|
||||
in other applications.
|
||||
</Tool>
|
||||
</ToolsSection>
|
||||
<ToolsSection title="Automation">
|
||||
<Tool title="AutoHotKey" href="https://autohotkey.com/">
|
||||
AutoHotKey features it's own scripting language and allows you to create keyboard macros for
|
||||
automating common tasks. For example, I use AutoHotKey to toggle between dark and light themes in
|
||||
Windows on the fly.
|
||||
</Tool>
|
||||
</ToolsSection>
|
||||
<ToolsSection title="Chrome Extensions">
|
||||
<Tool title="Bitwarden"
|
||||
href="https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb">
|
||||
Bitwarden is a free, open-source password manager, this Chrome Extension connects to a self-hosted
|
||||
instance of Bitwarden which lives on my Raspberry Pi. It is really useful for syncing passwords across
|
||||
devices.
|
||||
</Tool>
|
||||
<Tool title="uBlock Origin"
|
||||
href="https://chrome.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">
|
||||
Great extension for blocking those annoying YouTube ads and nasty tracking scripts.
|
||||
</Tool>
|
||||
<Tool title="Floccus"
|
||||
href="https://chrome.google.com/webstore/detail/floccus-bookmarks-sync/fnaicdffflnofjppbagibeoednhnbjhg">
|
||||
Floccus syncs your bookmarks across browsers and devices. It connects to my Nextcloud server via
|
||||
WebDAV and keeps my bookmarks in sync, so no matter which device I'm using, I always have the
|
||||
same set of bookmarks.
|
||||
</Tool>
|
||||
</ToolsSection>
|
||||
</div>
|
||||
</SimpleLayout>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import React from 'react'
|
||||
import {GetStaticProps} from 'next'
|
||||
import Head from 'next/head'
|
||||
import {Card} from '@/components/ui/Card'
|
||||
import {SimpleLayout} from '@/components/layouts/SimpleLayout'
|
||||
import {Views} from '@/components/ui/Views'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
import {Article} from 'types'
|
||||
|
||||
function Article({article}: { article: Article }) {
|
||||
return (
|
||||
<article>
|
||||
<Card variant="inline">
|
||||
<Card.Title href={`/writing/${article.slug}`}>
|
||||
{article.title}
|
||||
</Card.Title>
|
||||
<p className="flex order-first space-x-1 z-10 mb-3 md:mb-0 md:ml-4 md:order-last flex-shrink-0">
|
||||
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
|
||||
{formatDate(article.date)}
|
||||
</Card.Eyebrow>
|
||||
<Views
|
||||
slug={article.slug}
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400"
|
||||
shouldUpdateViews={false}
|
||||
/>
|
||||
</p>
|
||||
</Card>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArticlesIndex({articles}: { articles: Article[] }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Writing - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Writing on software engineering, and everything in between."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Writing - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Writing on software engineering, and everything in between."
|
||||
/>
|
||||
</Head>
|
||||
<SimpleLayout
|
||||
title="Writing on software engineering, and everything in between."
|
||||
intro="All of my long-form thoughts on software engineering, and more, displayed in chronological order."
|
||||
gradient="bg-gradient-to-r from-pink-500 to-violet-500"
|
||||
>
|
||||
<div className="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none">
|
||||
<div className="max-w-3xl space-y-16 mt-6">
|
||||
{articles.map((article) => (
|
||||
<Article key={article.slug} article={article}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SimpleLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
return {
|
||||
props: {
|
||||
articles: (await getAllArticles()).map(({component, ...meta}) => meta),
|
||||
}
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 239 KiB |
BIN
public/images/me.jpg
Normal file
After Width: | Height: | Size: 161 KiB |
Before Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 850 B |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 291 KiB |
Before Width: | Height: | Size: 166 KiB |
Before Width: | Height: | Size: 482 KiB |
@ -1,7 +1,11 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
content: ['./pages/**/*.{js,jsx,tsx}', './components/**/*.{js,jsx,tsx}'],
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./pages/**/*.{js,jsx,tsx}',
|
||||
'./components/**/*.{js,jsx,tsx}'
|
||||
],
|
||||
darkMode: 'class',
|
||||
plugins: [
|
||||
require('@tailwindcss/typography')
|
||||
|
@ -23,12 +23,18 @@
|
||||
"@/*": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|