Updated portfolio
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_REFRESH_TOKEN=
|
||||
NEXT_PUBLIC_SITE_URL=https://example.com
|
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
13
.gitignore
vendored
@ -1,8 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.idea/
|
||||
out/
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
@ -26,12 +23,14 @@ out/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# generated files
|
||||
/public/rss/
|
||||
.idea/
|
11
README.md
@ -0,0 +1,11 @@
|
||||
# Welcome to my portfolio website!
|
||||
|
||||
This website was built using React, Next.js and Tailwind CSS. It is designed to showcase my professional experience and skills, as
|
||||
well as provide information about me and my interests.
|
||||
|
||||
In addition to functioning as a personal blog, this website shows what I'm listening to on Spotify. This
|
||||
is a cool way to give visitors an insight into my personal music preferences and adds a dynamic element to the website. I hope to
|
||||
add additional features and content to my portfolio website in the future.
|
||||
|
||||
Feel free to explore the different sections, and contact me if you have any questions or would like to work
|
||||
together.
|
@ -1,9 +0,0 @@
|
||||
export default function ExternalLink() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" width="24"
|
||||
stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
)
|
||||
};
|
@ -1,11 +0,0 @@
|
||||
export default function GitHub() {
|
||||
return (
|
||||
<svg aria-hidden="true" height="24" viewBox="0 0 16 16" version="1.1" width="24"
|
||||
data-view-component="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
)
|
||||
};
|
@ -1,22 +0,0 @@
|
||||
import styles from '../styles/Home.module.scss';
|
||||
import ExternalLink from './ExternalLink';
|
||||
|
||||
export default function Projects({projectData}) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className={styles.description}>
|
||||
Personal Projects
|
||||
</h2>
|
||||
<div className={styles.grid}>
|
||||
{projectData.map((project, idx) => (
|
||||
<a href={project.href}
|
||||
className={styles.card}
|
||||
key={idx}>
|
||||
<h3>{project.name}<ExternalLink/></h3>
|
||||
<p>{project.description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
};
|
@ -1,27 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "Intellagent",
|
||||
"description": "Full stack help desk application powered by AI and machine learning.",
|
||||
"href": "https://github.com/r-freeman/intellagent-server"
|
||||
},
|
||||
{
|
||||
"name": "Resume",
|
||||
"description": "A fully customisable online resume created with Next.js.",
|
||||
"href": "https://github.com/r-freeman/resume"
|
||||
},
|
||||
{
|
||||
"name": "Super Mario Run",
|
||||
"description": "Mobile game inspired by Super Mario Bros, built with Phaser.",
|
||||
"href": "https://r-freeman.github.io/super-mario-run/"
|
||||
},
|
||||
{
|
||||
"name": "ASOS Prototype",
|
||||
"description": "Working prototype of ASOS' website built in Axure RP 8.",
|
||||
"href": "https://github.com/r-freeman/ASOS-Axure-Prototype"
|
||||
},
|
||||
{
|
||||
"name": "College App",
|
||||
"description": "Full stack application built with Laravel 6, Vue.js and Tailwind CSS.",
|
||||
"href": "https://github.com/r-freeman/college-app"
|
||||
}
|
||||
]
|
8
jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import fs from 'fs';
|
||||
|
||||
export function getCustomData(file) {
|
||||
const path = `${process.env.dataDirectory}/${file}`;
|
||||
|
||||
return JSON.parse(fs.readFileSync(path));
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
const path = require("path");
|
||||
const dataDirectory = path.join(process.cwd(), "data");
|
||||
|
||||
module.exports = {
|
||||
env: {dataDirectory},
|
||||
reactStrictMode: true,
|
||||
}
|
35
next.config.mjs
Normal file
@ -0,0 +1,35 @@
|
||||
import nextMDX from '@next/mdx'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypePrism from '@mapbox/rehype-prism'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
pageExtensions: ['jsx', 'js', 'mdx'],
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
experimental: {
|
||||
newNextLinkBehavior: true,
|
||||
scrollRestoration: true,
|
||||
},
|
||||
images: {
|
||||
domains: ['i.scdn.co']
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path',
|
||||
destination: 'https://ryanfreeman.dev/:path/',
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const withMDX = nextMDX({
|
||||
extension: /\.mdx?$/,
|
||||
options: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypePrism],
|
||||
},
|
||||
})
|
||||
|
||||
export default withMDX(nextConfig)
|
16671
package-lock.json
generated
35
package.json
@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "portfolio",
|
||||
"name": "tailwindui-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@ -7,15 +8,33 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"browserslist": "defaults, not ie <= 11",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^4.5.1",
|
||||
"next": "12.0.4",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"sass": "^1.43.4"
|
||||
"@headlessui/react": "^1.7.0",
|
||||
"@mapbox/rehype-prism": "^0.8.0",
|
||||
"@mdx-js/loader": "^2.1.2",
|
||||
"@next/mdx": "^12.2.3",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"clsx": "^1.2.0",
|
||||
"fast-glob": "^3.2.11",
|
||||
"feed": "^4.2.2",
|
||||
"focus-visible": "^5.2.0",
|
||||
"motion": "^10.15.3",
|
||||
"next": "^12.3.0",
|
||||
"node-fetch": "^3.3.0",
|
||||
"postcss-focus-visible": "^6.0.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"swr": "^2.0.0",
|
||||
"tailwindcss": "^3.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-next": "12.0.4"
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"eslint": "8.19.0",
|
||||
"eslint-config-next": "12.2.5",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.11"
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import '../styles/globals.scss';
|
||||
|
||||
function App({Component, pageProps}) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
|
||||
export default App;
|
@ -1,17 +0,0 @@
|
||||
import Document, {Html, Head, Main, NextScript} from 'next/document';
|
||||
|
||||
class Doc extends Document {
|
||||
render() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head/>
|
||||
<body>
|
||||
<Main/>
|
||||
<NextScript/>
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Doc;
|
@ -1,51 +0,0 @@
|
||||
import Head from 'next/head';
|
||||
import styles from '../styles/Home.module.scss';
|
||||
import GitHub from '../components/GitHub';
|
||||
import {getCustomData} from '../lib/custom';
|
||||
import Projects from '../components/Projects';
|
||||
|
||||
export default function Home({projectData}) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Ryan Freeman | Full Stack Developer in Dublin, Ireland</title>
|
||||
<meta name="author" content="Ryan Freeman"/>
|
||||
<meta name="description" content="Full Stack Developer in Dublin, Ireland"/>
|
||||
<meta name="keywords" content="React, JavaScript, Developer"/>
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:url" content="https://ryanfreeman.dev"/>
|
||||
<meta property="og:site_name" content="Ryan Freeman"/>
|
||||
<meta property="og:title" content="Ryan Freeman | Full Stack Developer in Dublin, Ireland"/>
|
||||
<meta property="og:description" content="Full Stack Developer in Dublin, Ireland"/>
|
||||
<link rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>👋</text></svg>"/>
|
||||
</Head>
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Ryan Freeman{' '}<span>Full Stack Developer</span>
|
||||
</h1>
|
||||
<p className={styles.bio}>
|
||||
I'm currently working as a Software Engineer at Aer Lingus ✈️
|
||||
</p>
|
||||
<div className={styles.links}>
|
||||
<a href="https://github.com/r-freeman">
|
||||
<GitHub/>
|
||||
<span className={styles.srOnly}>GitHub profile</span>
|
||||
</a>
|
||||
</div>
|
||||
<Projects
|
||||
projectData={projectData}/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticProps() {
|
||||
const projectData = getCustomData('projects.json');
|
||||
|
||||
return {
|
||||
props: {
|
||||
projectData
|
||||
}
|
||||
}
|
||||
}
|
9
postcss.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'postcss-focus-visible': {
|
||||
replaceWith: '[data-focus-visible-added]',
|
||||
},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
5
prettier.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
plugins: [require('prettier-plugin-tailwindcss')],
|
||||
}
|
BIN
public/Ryan Freeman CV.pdf
Normal file
@ -1,2 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /*
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
Sitemap: https://ryanfreeman.dev/sitemap.xml
|
@ -1,14 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
|
||||
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>1.00</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/about</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/writing</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/projects</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/uses</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/writing/how-to-add-typescript-to-an-existing-nextjs-project</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/writing/a-personal-journey-in-software-engineering</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/Ryan%20Freeman%20CV.pdf</loc>
|
||||
<lastmod>2023-01-02T00:44:06+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
|
||||
|
||||
<url>
|
||||
<loc>https://ryanfreeman.dev/</loc>
|
||||
<lastmod>2021-10-18T19:53:53+00:00</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
</urlset>
|
BIN
public/static/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 53 KiB |
BIN
public/static/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 850 B |
BIN
public/static/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
public/static/icons/favicon.ico
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
public/static/images/jens-lelie-u0vgcIOQG08-unsplash.jpg
Normal file
After Width: | Height: | Size: 319 KiB |
BIN
public/static/images/photo-of-me-lg.jpg
Normal file
After Width: | Height: | Size: 166 KiB |
120
src/components/ArticleLayout.jsx
Normal file
@ -0,0 +1,120 @@
|
||||
import Head from 'next/head'
|
||||
import {useRouter} from 'next/router'
|
||||
|
||||
import {Container} from '@/components/Container'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
import {Prose} from '@/components/Prose'
|
||||
|
||||
function ArrowLeftIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M7.25 11.25 3.75 8m0 0 3.5-3.25M3.75 8h8.5"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArticleLayout({
|
||||
children,
|
||||
meta,
|
||||
isRssFeed = false,
|
||||
previousPathname,
|
||||
}) {
|
||||
let router = useRouter()
|
||||
|
||||
if (isRssFeed) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{`${meta.title} - Ryan Freeman`}</title>
|
||||
<meta name="description" content={meta.description}/>
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
|
||||
/>
|
||||
<meta
|
||||
property="og:type"
|
||||
content="website"
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={meta.title}
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={meta.description}
|
||||
/>
|
||||
{meta.ogImage &&
|
||||
<>
|
||||
<meta
|
||||
property="og:image"
|
||||
content={meta.ogImage}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:card"
|
||||
content="summary_large_image"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={meta.ogImage}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
<meta
|
||||
property="twitter:domain"
|
||||
content="ryanfreeman.dev"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content={meta.title}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content={meta.description}
|
||||
/>
|
||||
</Head>
|
||||
<Container className="mt-16 lg:mt-32">
|
||||
<div className="xl:relative">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{previousPathname && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Go back to articles"
|
||||
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:mb-0 lg:-mt-2 xl:-top-1.5 xl:left-0 xl:mt-0"
|
||||
>
|
||||
<ArrowLeftIcon
|
||||
className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400"/>
|
||||
</button>
|
||||
)}
|
||||
<article>
|
||||
<header className="flex flex-col">
|
||||
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<time
|
||||
dateTime={meta.date}
|
||||
className="order-first flex items-center text-base text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
<span>{formatDate(meta.date)}</span>
|
||||
</time>
|
||||
</header>
|
||||
<Prose className="mt-8">{children}</Prose>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
23
src/components/Button.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
'bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70',
|
||||
secondary:
|
||||
'bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-indigo-700 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70',
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', className, href, ...props }) {
|
||||
className = clsx(
|
||||
'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
|
||||
variantStyles[variant],
|
||||
className
|
||||
)
|
||||
|
||||
return href ? (
|
||||
<Link href={href} className={className} {...props} />
|
||||
) : (
|
||||
<button className={className} {...props} />
|
||||
)
|
||||
}
|
94
src/components/Card.jsx
Normal file
@ -0,0 +1,94 @@
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
function ChevronRightIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M6.75 5.75 9.25 8l-2.5 2.25"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Card({ as: Component = 'div', className, children }) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(className, 'group relative flex flex-col items-start')}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Link = function CardLink({ children, ...props }) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute -inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
<Link {...props}>
|
||||
<span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
<span className="relative z-10">{children}</span>
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Title = function CardTitle({ as: Component = 'h2', href, children }) {
|
||||
return (
|
||||
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Description = function CardDescription({ children }) {
|
||||
return (
|
||||
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Cta = function CardCta({ children }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-4 flex items-center text-sm font-medium text-zinc-400 group-hover:text-indigo-500 dark:text-zinc-200"
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-1 h-4 w-4 stroke-current" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Eyebrow = function CardEyebrow({
|
||||
as: Component = 'p',
|
||||
decorate = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
className,
|
||||
'relative z-10 order-first mb-3 flex items-center text-sm text-zinc-500 dark:text-zinc-400',
|
||||
decorate && 'pl-3.5'
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{decorate && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
42
src/components/Container.jsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { forwardRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const OuterContainer = forwardRef(function OuterContainer(
|
||||
{ className, children, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div ref={ref} className={clsx('sm:px-8', className)} {...props}>
|
||||
<div className="mx-auto max-w-7xl lg:px-8">{children}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const InnerContainer = forwardRef(function InnerContainer(
|
||||
{ className, children, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('relative px-4 sm:px-8 lg:px-12', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto max-w-2xl lg:max-w-5xl">{children}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Container = forwardRef(function Container(
|
||||
{ children, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<OuterContainer ref={ref} {...props}>
|
||||
<InnerContainer>{children}</InnerContainer>
|
||||
</OuterContainer>
|
||||
)
|
||||
})
|
||||
|
||||
Container.Outer = OuterContainer
|
||||
Container.Inner = InnerContainer
|
37
src/components/Footer.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import {Container} from '@/components/Container'
|
||||
import {SpotifyPlayer} from '@/components/SpotifyPlayer'
|
||||
|
||||
function NavLink({href, children}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="transition hover:text-indigo-500 dark:hover:text-indigo-400"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-32">
|
||||
<Container.Outer>
|
||||
<div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
|
||||
<Container.Inner>
|
||||
<SpotifyPlayer/>
|
||||
<div className="flex flex-col items-center justify-between gap-6 mt-12">
|
||||
<div className="flex gap-6 text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||
<NavLink href="/about">About</NavLink>
|
||||
<NavLink href="/writing">Writing</NavLink>
|
||||
<NavLink href="/projects">Projects</NavLink>
|
||||
<NavLink href="/uses">Uses</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container.Inner>
|
||||
</div>
|
||||
</Container.Outer>
|
||||
</footer>
|
||||
)
|
||||
}
|
432
src/components/Header.jsx
Normal file
@ -0,0 +1,432 @@
|
||||
import Image from 'next/future/image'
|
||||
import Link from 'next/link'
|
||||
import {useRouter} from 'next/router'
|
||||
import {Popover, Transition} from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import {Container} from '@/components/Container'
|
||||
import avatar from '@/images/avatar.jpg'
|
||||
import {Fragment, useEffect, useRef} from 'react'
|
||||
|
||||
function CloseIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="m17.25 6.75-10.5 10.5M6.75 6.75l10.5 10.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M1.75 1.75 4 4.25l2.25-2.5"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SunIcon(props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 12.25A4.25 4.25 0 0 1 12.25 8v0a4.25 4.25 0 0 1 4.25 4.25v0a4.25 4.25 0 0 1-4.25 4.25v0A4.25 4.25 0 0 1 8 12.25v0Z"/>
|
||||
<path
|
||||
d="M12.25 3v1.5M21.5 12.25H20M18.791 18.791l-1.06-1.06M18.791 5.709l-1.06 1.06M12.25 20v1.5M4.5 12.25H3M6.77 6.77 5.709 5.709M6.77 17.73l-1.061 1.061"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M17.25 16.22a6.937 6.937 0 0 1-9.47-9.47 7.451 7.451 0 1 0 9.47 9.47ZM12.75 7C17 7 17 2.75 17 2.75S17 7 21.25 7C17 7 17 11.25 17 11.25S17 7 12.75 7Z"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNavItem({href, children}) {
|
||||
return (
|
||||
<li>
|
||||
<Popover.Button as={Link} href={href} className="block py-2">
|
||||
{children}
|
||||
</Popover.Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNavigation(props) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<Popover.Button
|
||||
className="group flex items-center rounded-full bg-white/90 px-4 py-2 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10 dark:hover:ring-white/20">
|
||||
Menu
|
||||
<ChevronDownIcon
|
||||
className="ml-3 h-auto w-2 stroke-zinc-500 group-hover:stroke-zinc-700 dark:group-hover:stroke-zinc-400"/>
|
||||
</Popover.Button>
|
||||
<Transition.Root>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Popover.Overlay className="fixed inset-0 z-50 bg-zinc-800/40 backdrop-blur-sm dark:bg-black/80"/>
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel
|
||||
focus
|
||||
className="fixed inset-x-4 top-8 z-50 origin-top rounded-3xl bg-white p-8 ring-1 ring-zinc-900/5 dark:bg-zinc-900 dark:ring-zinc-800"
|
||||
>
|
||||
<div className="flex flex-row-reverse items-center justify-between">
|
||||
<Popover.Button aria-label="Close menu" className="-m-1 p-1">
|
||||
<CloseIcon className="h-6 w-6 text-zinc-500 dark:text-zinc-400"/>
|
||||
</Popover.Button>
|
||||
<h2 className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
Navigation
|
||||
</h2>
|
||||
</div>
|
||||
<nav className="mt-6">
|
||||
<ul className="-my-2 divide-y divide-zinc-100 text-base text-zinc-800 dark:divide-zinc-100/5 dark:text-zinc-300">
|
||||
<MobileNavItem href="/about">About</MobileNavItem>
|
||||
<MobileNavItem href="/writing">Writing</MobileNavItem>
|
||||
<MobileNavItem href="/projects">Projects</MobileNavItem>
|
||||
<MobileNavItem href="/uses">Uses</MobileNavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
</Popover.Panel>
|
||||
</Transition.Child>
|
||||
</Transition.Root>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({href, children}) {
|
||||
let isActive = useRouter().pathname === href
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={href}
|
||||
className={clsx(
|
||||
'relative block px-3 py-2 transition',
|
||||
isActive
|
||||
? 'text-indigo-500 dark:text-indigo-400'
|
||||
: 'hover:text-indigo-500 dark:hover:text-indigo-400'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute inset-x-1 -bottom-px h-px bg-gradient-to-r from-indigo-500/0 via-indigo-500/40 to-indigo-500/0 dark:from-indigo-400/0 dark:via-indigo-400/40 dark:to-indigo-400/0"/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopNavigation(props) {
|
||||
return (
|
||||
<nav {...props}>
|
||||
<ul className="flex rounded-full bg-white/90 px-3 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10">
|
||||
<NavItem href="/about">About</NavItem>
|
||||
<NavItem href="/writing">Writing</NavItem>
|
||||
<NavItem href="/projects">Projects</NavItem>
|
||||
<NavItem href="/uses">Uses</NavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeToggle() {
|
||||
function disableTransitionsTemporarily() {
|
||||
document.documentElement.classList.add('[&_*]:!transition-none')
|
||||
window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('[&_*]:!transition-none')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
disableTransitionsTemporarily()
|
||||
|
||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
let isSystemDarkMode = darkModeMediaQuery.matches
|
||||
let isDarkMode = document.documentElement.classList.toggle('dark')
|
||||
|
||||
if (isDarkMode === isSystemDarkMode) {
|
||||
delete window.localStorage.isDarkMode
|
||||
} else {
|
||||
window.localStorage.isDarkMode = isDarkMode
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle dark mode"
|
||||
className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
|
||||
onClick={toggleMode}
|
||||
>
|
||||
<SunIcon
|
||||
className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-indigo-50 [@media(prefers-color-scheme:dark)]:stroke-indigo-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-indigo-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-indigo-600"/>
|
||||
<MoonIcon
|
||||
className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-indigo-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-indigo-500"/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(number, a, b) {
|
||||
let min = Math.min(a, b)
|
||||
let max = Math.max(a, b)
|
||||
return Math.min(Math.max(number, min), max)
|
||||
}
|
||||
|
||||
function AvatarContainer({className, ...props}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'h-10 w-10 rounded-full bg-white/90 p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:ring-white/10'
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({large = false, className, ...props}) {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Home"
|
||||
className={clsx(className, 'pointer-events-auto')}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
src={avatar}
|
||||
alt=""
|
||||
sizes={large ? '4rem' : '2.25rem'}
|
||||
className={clsx(
|
||||
'rounded-full bg-zinc-100 object-cover dark:bg-zinc-800',
|
||||
large ? 'h-16 w-16' : 'h-9 w-9'
|
||||
)}
|
||||
priority
|
||||
placeholder="blur"
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
let isHomePage = useRouter().pathname === '/'
|
||||
|
||||
let headerRef = useRef()
|
||||
let avatarRef = useRef()
|
||||
let isInitial = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
let downDelay = avatarRef.current?.offsetTop ?? 0
|
||||
let upDelay = 64
|
||||
|
||||
function setProperty(property, value) {
|
||||
document.documentElement.style.setProperty(property, value)
|
||||
}
|
||||
|
||||
function removeProperty(property) {
|
||||
document.documentElement.style.removeProperty(property)
|
||||
}
|
||||
|
||||
function updateHeaderStyles() {
|
||||
let {top, height} = headerRef.current.getBoundingClientRect()
|
||||
let scrollY = clamp(
|
||||
window.scrollY,
|
||||
0,
|
||||
document.body.scrollHeight - window.innerHeight
|
||||
)
|
||||
|
||||
if (isInitial.current) {
|
||||
setProperty('--header-position', 'sticky')
|
||||
}
|
||||
|
||||
setProperty('--content-offset', `${downDelay}px`)
|
||||
|
||||
if (isInitial.current || scrollY < downDelay) {
|
||||
setProperty('--header-height', `${downDelay + height}px`)
|
||||
setProperty('--header-mb', `${-downDelay}px`)
|
||||
} else if (top + height < -upDelay) {
|
||||
let offset = Math.max(height, scrollY - upDelay)
|
||||
setProperty('--header-height', `${offset}px`)
|
||||
setProperty('--header-mb', `${height - offset}px`)
|
||||
} else if (top === 0) {
|
||||
setProperty('--header-height', `${scrollY + height}px`)
|
||||
setProperty('--header-mb', `${-scrollY}px`)
|
||||
}
|
||||
|
||||
if (top === 0 && scrollY > 0 && scrollY >= downDelay) {
|
||||
setProperty('--header-inner-position', 'fixed')
|
||||
removeProperty('--header-top')
|
||||
removeProperty('--avatar-top')
|
||||
} else {
|
||||
removeProperty('--header-inner-position')
|
||||
setProperty('--header-top', '0px')
|
||||
setProperty('--avatar-top', '0px')
|
||||
}
|
||||
}
|
||||
|
||||
function updateAvatarStyles() {
|
||||
if (!isHomePage) {
|
||||
return
|
||||
}
|
||||
|
||||
let fromScale = 1
|
||||
let toScale = 36 / 64
|
||||
let fromX = 0
|
||||
let toX = 2 / 16
|
||||
|
||||
let scrollY = downDelay - window.scrollY
|
||||
|
||||
let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale
|
||||
scale = clamp(scale, fromScale, toScale)
|
||||
|
||||
let x = (scrollY * (fromX - toX)) / downDelay + toX
|
||||
x = clamp(x, fromX, toX)
|
||||
|
||||
setProperty(
|
||||
'--avatar-image-transform',
|
||||
`translate3d(${x}rem, 0, 0) scale(${scale})`
|
||||
)
|
||||
|
||||
let borderScale = 1 / (toScale / scale)
|
||||
let borderX = (-toX + x) * borderScale
|
||||
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
|
||||
|
||||
setProperty('--avatar-border-transform', borderTransform)
|
||||
setProperty('--avatar-border-opacity', scale === toScale ? 1 : 0)
|
||||
}
|
||||
|
||||
function updateStyles() {
|
||||
updateHeaderStyles()
|
||||
updateAvatarStyles()
|
||||
isInitial.current = false
|
||||
}
|
||||
|
||||
updateStyles()
|
||||
window.addEventListener('scroll', updateStyles, {passive: true})
|
||||
window.addEventListener('resize', updateStyles)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateStyles, {passive: true})
|
||||
window.removeEventListener('resize', updateStyles)
|
||||
}
|
||||
}, [isHomePage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="pointer-events-none relative z-50 flex flex-col"
|
||||
style={{
|
||||
height: 'var(--header-height)',
|
||||
marginBottom: 'var(--header-mb)',
|
||||
}}
|
||||
>
|
||||
{isHomePage && (
|
||||
<>
|
||||
<div
|
||||
ref={avatarRef}
|
||||
className="order-last mt-[calc(theme(spacing.16)-theme(spacing.3))]"
|
||||
/>
|
||||
<Container
|
||||
className="top-0 order-last -mb-3 pt-3"
|
||||
style={{position: 'var(--header-position)'}}
|
||||
>
|
||||
<div
|
||||
className="top-[var(--avatar-top,theme(spacing.3))] w-full"
|
||||
style={{position: 'var(--header-inner-position)'}}
|
||||
>
|
||||
<div className="relative">
|
||||
<AvatarContainer
|
||||
className="absolute left-0 top-3 origin-left transition-opacity"
|
||||
style={{
|
||||
opacity: 'var(--avatar-border-opacity, 0)',
|
||||
transform: 'var(--avatar-border-transform)',
|
||||
}}
|
||||
/>
|
||||
<Avatar
|
||||
large
|
||||
className="block h-16 w-16 origin-left"
|
||||
style={{transform: 'var(--avatar-image-transform)'}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="top-0 z-10 h-16 pt-6"
|
||||
style={{position: 'var(--header-position)'}}
|
||||
>
|
||||
<Container
|
||||
className="top-[var(--header-top,theme(spacing.6))] w-full"
|
||||
style={{position: 'var(--header-inner-position)'}}
|
||||
>
|
||||
<div className="relative flex gap-4">
|
||||
<div className="flex flex-1">
|
||||
{!isHomePage && (
|
||||
<AvatarContainer>
|
||||
<Avatar/>
|
||||
</AvatarContainer>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 justify-end md:justify-center">
|
||||
<MobileNavigation className="pointer-events-auto md:hidden"/>
|
||||
<DesktopNavigation className="pointer-events-auto hidden md:block"/>
|
||||
</div>
|
||||
<div className="flex justify-end md:flex-1">
|
||||
<div className="pointer-events-auto">
|
||||
<ModeToggle/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</header>
|
||||
{isHomePage && <div style={{height: 'var(--content-offset)'}}/>}
|
||||
</>
|
||||
)
|
||||
}
|
7
src/components/Prose.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function Prose({ children, className }) {
|
||||
return (
|
||||
<div className={clsx(className, 'prose dark:prose-invert')}>{children}</div>
|
||||
)
|
||||
}
|
22
src/components/Section.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useId } from 'react'
|
||||
|
||||
export function Section({ title, children }) {
|
||||
let id = useId()
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={id}
|
||||
className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40"
|
||||
>
|
||||
<div className="grid max-w-3xl grid-cols-1 items-baseline gap-y-8 md:grid-cols-4">
|
||||
<h2
|
||||
id={id}
|
||||
className="text-sm font-semibold text-zinc-800 dark:text-zinc-100"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="md:col-span-3">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
21
src/components/SimpleLayout.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
import {Container} from '@/components/Container'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function SimpleLayout({title, intro, children, gradient}) {
|
||||
return (
|
||||
<Container className="mt-16 sm:mt-32">
|
||||
<header className="max-w-2xl">
|
||||
<h1
|
||||
className={clsx(gradient
|
||||
? `${gradient} bg-clip-text dark:text-transparent`
|
||||
: '', 'text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl')}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{intro}
|
||||
</p>
|
||||
</header>
|
||||
<div className="mt-16 sm:mt-20">{children}</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
36
src/components/SocialIcons.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
export function TwitterIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path d="M20.055 7.983c.011.174.011.347.011.523 0 5.338-3.92 11.494-11.09 11.494v-.003A10.755 10.755 0 0 1 3 18.186c.308.038.618.057.928.058a7.655 7.655 0 0 0 4.841-1.733c-1.668-.032-3.13-1.16-3.642-2.805a3.753 3.753 0 0 0 1.76-.07C5.07 13.256 3.76 11.6 3.76 9.676v-.05a3.77 3.77 0 0 0 1.77.505C3.816 8.945 3.288 6.583 4.322 4.737c1.98 2.524 4.9 4.058 8.034 4.22a4.137 4.137 0 0 1 1.128-3.86A3.807 3.807 0 0 1 19 5.274a7.657 7.657 0 0 0 2.475-.98c-.29.934-.9 1.729-1.713 2.233A7.54 7.54 0 0 0 22 5.89a8.084 8.084 0 0 1-1.945 2.093Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function InstagramIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path d="M12 3c-2.444 0-2.75.01-3.71.054-.959.044-1.613.196-2.185.418A4.412 4.412 0 0 0 4.51 4.511c-.5.5-.809 1.002-1.039 1.594-.222.572-.374 1.226-.418 2.184C3.01 9.25 3 9.556 3 12s.01 2.75.054 3.71c.044.959.196 1.613.418 2.185.23.592.538 1.094 1.039 1.595.5.5 1.002.808 1.594 1.038.572.222 1.226.374 2.184.418C9.25 20.99 9.556 21 12 21s2.75-.01 3.71-.054c.959-.044 1.613-.196 2.185-.419a4.412 4.412 0 0 0 1.595-1.038c.5-.5.808-1.002 1.038-1.594.222-.572.374-1.226.418-2.184.044-.96.054-1.267.054-3.711s-.01-2.75-.054-3.71c-.044-.959-.196-1.613-.419-2.185A4.412 4.412 0 0 0 19.49 4.51c-.5-.5-1.002-.809-1.594-1.039-.572-.222-1.226-.374-2.184-.418C14.75 3.01 14.444 3 12 3Zm0 1.622c2.403 0 2.688.009 3.637.052.877.04 1.354.187 1.67.31.421.163.72.358 1.036.673.315.315.51.615.673 1.035.123.317.27.794.31 1.671.043.95.052 1.234.052 3.637s-.009 2.688-.052 3.637c-.04.877-.187 1.354-.31 1.67-.163.421-.358.72-.673 1.036a2.79 2.79 0 0 1-1.035.673c-.317.123-.794.27-1.671.31-.95.043-1.234.052-3.637.052s-2.688-.009-3.637-.052c-.877-.04-1.354-.187-1.67-.31a2.789 2.789 0 0 1-1.036-.673 2.79 2.79 0 0 1-.673-1.035c-.123-.317-.27-.794-.31-1.671-.043-.95-.052-1.234-.052-3.637s.009-2.688.052-3.637c.04-.877.187-1.354.31-1.67.163-.421.358-.72.673-1.036.315-.315.615-.51 1.035-.673.317-.123.794-.27 1.671-.31.95-.043 1.234-.052 3.637-.052Z" />
|
||||
<path d="M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm0-7.622a4.622 4.622 0 1 0 0 9.244 4.622 4.622 0 0 0 0-9.244Zm5.884-.182a1.08 1.08 0 1 1-2.16 0 1.08 1.08 0 0 1 2.16 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.475 2 2 6.588 2 12.253c0 4.537 2.862 8.369 6.838 9.727.5.09.687-.218.687-.487 0-.243-.013-1.05-.013-1.91C7 20.059 6.35 18.957 6.15 18.38c-.113-.295-.6-1.205-1.025-1.448-.35-.192-.85-.667-.013-.68.788-.012 1.35.744 1.538 1.051.9 1.551 2.338 1.116 2.912.846.088-.666.35-1.115.638-1.371-2.225-.256-4.55-1.14-4.55-5.062 0-1.115.387-2.038 1.025-2.756-.1-.256-.45-1.307.1-2.717 0 0 .837-.269 2.75 1.051.8-.23 1.65-.346 2.5-.346.85 0 1.7.115 2.5.346 1.912-1.333 2.75-1.05 2.75-1.05.55 1.409.2 2.46.1 2.716.637.718 1.025 1.628 1.025 2.756 0 3.934-2.337 4.806-4.562 5.062.362.32.675.936.675 1.897 0 1.371-.013 2.473-.013 2.82 0 .268.188.589.688.486a10.039 10.039 0 0 0 4.932-3.74A10.447 10.447 0 0 0 22 12.253C22 6.588 17.525 2 12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinkedInIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props}>
|
||||
<path d="M18.335 18.339H15.67v-4.177c0-.996-.02-2.278-1.39-2.278-1.389 0-1.601 1.084-1.601 2.205v4.25h-2.666V9.75h2.56v1.17h.035c.358-.674 1.228-1.387 2.528-1.387 2.7 0 3.2 1.778 3.2 4.091v4.715zM7.003 8.575a1.546 1.546 0 01-1.548-1.549 1.548 1.548 0 111.547 1.549zm1.336 9.764H5.666V9.75H8.34v8.589zM19.67 3H4.329C3.593 3 3 3.58 3 4.297v15.406C3 20.42 3.594 21 4.328 21h15.338C20.4 21 21 20.42 21 19.703V4.297C21 3.58 20.4 3 19.666 3h.003z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
164
src/components/SpotifyPlayer.jsx
Normal file
@ -0,0 +1,164 @@
|
||||
import useSWR from 'swr'
|
||||
import fetcher from '@/lib/fetcher'
|
||||
import Image from 'next/future/image'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
import {useEffect} from 'react'
|
||||
import {animate} from 'motion'
|
||||
|
||||
function AnimatedBars() {
|
||||
useEffect(() => {
|
||||
animate(
|
||||
'#bar1',
|
||||
{
|
||||
transform: [
|
||||
'scaleY(1.0) translateY(0rem)',
|
||||
'scaleY(1.5) translateY(-0.082rem)',
|
||||
'scaleY(1.0) translateY(0rem)'
|
||||
]
|
||||
},
|
||||
{
|
||||
duration: 1.0,
|
||||
repeat: Infinity,
|
||||
easing: ['ease-in-out']
|
||||
}
|
||||
);
|
||||
animate(
|
||||
'#bar2',
|
||||
{
|
||||
transform: [
|
||||
'scaleY(1.0) translateY(0rem)',
|
||||
'scaleY(3) translateY(-0.083rem)',
|
||||
'scaleY(1.0) translateY(0rem)'
|
||||
]
|
||||
},
|
||||
{
|
||||
delay: 0.2,
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
easing: ['ease-in-out']
|
||||
}
|
||||
);
|
||||
animate(
|
||||
'#bar3',
|
||||
{
|
||||
transform: [
|
||||
'scaleY(1.0) translateY(0rem)',
|
||||
'scaleY(0.5) translateY(0.37rem)',
|
||||
'scaleY(1.0) translateY(0rem)'
|
||||
]
|
||||
},
|
||||
{
|
||||
delay: 0.3,
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
easing: ['ease-in-out']
|
||||
}
|
||||
);
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-auto flex items-end overflow-hidden flex-shrink-0">
|
||||
<span
|
||||
id="bar1"
|
||||
className="w-1 mr-[3px] h-2 bg-gray-300 dark:bg-green-950"
|
||||
/>
|
||||
<span
|
||||
id="bar2"
|
||||
className="w-1 mr-[3px] h-1 bg-gray-300 dark:bg-green-950"
|
||||
/>
|
||||
<span
|
||||
id="bar3"
|
||||
className="w-1 h-3 bg-gray-300 dark:bg-green-950"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function usePlayerState(path, options) {
|
||||
const {data, error, isLoading} = useSWR(`/api/spotify/${path}`, fetcher, options)
|
||||
|
||||
return {
|
||||
song: data,
|
||||
isLoading,
|
||||
isError: error
|
||||
}
|
||||
}
|
||||
|
||||
function Song({artist, title, songUrl, album, albumImageUrl, isPlaying}) {
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
{isPlaying &&
|
||||
<AnimatedBars/>
|
||||
}
|
||||
<Image
|
||||
width="64"
|
||||
height="64"
|
||||
alt={album}
|
||||
src={albumImageUrl}
|
||||
className="aspect-square rounded-2xl object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h2 className={clsx(isPlaying ? 'dark:text-green-950' : 'dark:text-zinc-100', 'text-sm font-semibold text-zinc-800')}>
|
||||
<Link href={songUrl}>
|
||||
{title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none">{artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SongSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center space-x-4 animate-pulse">
|
||||
<div
|
||||
className="w-[64px] h-[64px] bg-zinc-100 rounded-2xl dark:bg-zinc-900"
|
||||
/>
|
||||
<div>
|
||||
<p className="w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
|
||||
<p className="mt-3 w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentlyPlaying(props) {
|
||||
return (
|
||||
<Song {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
function LastPlayed() {
|
||||
const {song, isLoading, isError} = usePlayerState('last-played')
|
||||
|
||||
if (isError) return
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading
|
||||
? <SongSkeleton/>
|
||||
: song?.title &&
|
||||
<Song {...song} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpotifyPlayer() {
|
||||
const {song, isLoading, isError} = usePlayerState('currently-playing', {refreshInterval: 30000})
|
||||
|
||||
if (isError) return
|
||||
|
||||
return (
|
||||
<div className="grid place-items-center sm:place-items-start">
|
||||
{isLoading
|
||||
? <SongSkeleton/>
|
||||
: song?.isPlaying
|
||||
? <CurrentlyPlaying {...song}/>
|
||||
: <LastPlayed/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
BIN
src/images/avatar.jpg
Normal file
After Width: | Height: | Size: 291 KiB |
BIN
src/images/aws-certified-cloud-practitioner-badge.png
Normal file
After Width: | Height: | Size: 53 KiB |
BIN
src/images/photo-of-me-lg.jpg
Normal file
After Width: | Height: | Size: 482 KiB |
3
src/lib/fetcher.js
Normal file
@ -0,0 +1,3 @@
|
||||
const fetcher = (...args) => fetch(...args).then(res => res.json())
|
||||
|
||||
export default fetcher
|
8
src/lib/formatDate.js
Normal file
@ -0,0 +1,8 @@
|
||||
export function formatDate(dateString) {
|
||||
return new Date(`${dateString}T00:00:00Z`).toLocaleDateString('en-IE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
53
src/lib/generateRssFeed.js
Normal file
@ -0,0 +1,53 @@
|
||||
import ReactDOMServer from 'react-dom/server'
|
||||
import {Feed} from 'feed'
|
||||
import {mkdir, writeFile} from 'fs/promises'
|
||||
|
||||
import {getAllArticles} from './getAllArticles'
|
||||
|
||||
export async function generateRssFeed() {
|
||||
let articles = await getAllArticles()
|
||||
let siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
let author = {
|
||||
name: 'Ryan Freeman',
|
||||
email: 'hello@ryanfreeman.dev',
|
||||
}
|
||||
|
||||
let feed = new Feed({
|
||||
title: author.name,
|
||||
description: 'Hi. I\'m Ryan, a software engineer based in Dublin, Ireland. I\'m currently working in the aviation industry for Aer Lingus. I am passionate about personal growth and progressing in my career. This is my personal website where you can learn more about me, read articles Ive written and see projects I\'ve worked on.',
|
||||
author,
|
||||
id: siteUrl,
|
||||
link: siteUrl,
|
||||
image: `${siteUrl}/static/icons/favicon.ico`,
|
||||
favicon: `${siteUrl}/static/icons/favicon.ico`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}`,
|
||||
feedLinks: {
|
||||
rss2: `${siteUrl}/rss/feed.xml`,
|
||||
json: `${siteUrl}/rss/feed.json`,
|
||||
},
|
||||
})
|
||||
|
||||
for (let article of articles) {
|
||||
let url = `${siteUrl}/writing/${article.slug}`
|
||||
let html = ReactDOMServer.renderToStaticMarkup(
|
||||
<article.component isRssFeed/>
|
||||
)
|
||||
|
||||
feed.addItem({
|
||||
title: article.title,
|
||||
id: url,
|
||||
link: url,
|
||||
description: article.description,
|
||||
content: html,
|
||||
author: [author],
|
||||
contributor: [author],
|
||||
date: new Date(article.date),
|
||||
})
|
||||
}
|
||||
|
||||
await mkdir('./public/rss', {recursive: true})
|
||||
await Promise.all([
|
||||
writeFile('./public/rss/feed.xml', feed.rss2(), 'utf8'),
|
||||
writeFile('./public/rss/feed.json', feed.json1(), 'utf8'),
|
||||
])
|
||||
}
|
23
src/lib/getAllArticles.js
Normal file
@ -0,0 +1,23 @@
|
||||
import glob from 'fast-glob'
|
||||
import * as path from 'path'
|
||||
|
||||
async function importArticle(articleFilename) {
|
||||
let { meta, default: component } = await import(
|
||||
`../pages/writing/${articleFilename}`
|
||||
)
|
||||
return {
|
||||
slug: articleFilename.replace(/(\/index)?\.mdx$/, ''),
|
||||
...meta,
|
||||
component,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllArticles() {
|
||||
let articleFilenames = await glob(['*.mdx', '*/index.mdx'], {
|
||||
cwd: path.join(process.cwd(), 'src/pages/writing'),
|
||||
})
|
||||
|
||||
let articles = await Promise.all(articleFilenames.map(importArticle))
|
||||
|
||||
return articles.sort((a, z) => new Date(z.date) - new Date(a.date))
|
||||
}
|
46
src/lib/spotify.js
Normal file
@ -0,0 +1,46 @@
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID
|
||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET
|
||||
const SPOTIFY_REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN
|
||||
const SPOTIFY_TOKEN = "https://accounts.spotify.com/api/token"
|
||||
const SPOTIFY_CURRENTLY_PLAYING = "https://api.spotify.com/v1/me/player/currently-playing"
|
||||
const SPOTIFY_RECENTLY_PLAYED = "https://api.spotify.com/v1/me/player/recently-played"
|
||||
|
||||
const basic = Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')
|
||||
|
||||
const getAccessToken = async () => {
|
||||
const response = await fetch(SPOTIFY_TOKEN, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': `${SPOTIFY_REFRESH_TOKEN}`
|
||||
})
|
||||
})
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const getCurrentlyPlaying = async () => {
|
||||
const {access_token} = await getAccessToken()
|
||||
|
||||
return await fetch(SPOTIFY_CURRENTLY_PLAYING, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getRecentlyPlayed = async () => {
|
||||
const {access_token} = await getAccessToken()
|
||||
|
||||
return await fetch(SPOTIFY_RECENTLY_PLAYED, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`
|
||||
}
|
||||
})
|
||||
}
|
38
src/pages/_app.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@/components/Header'
|
||||
|
||||
import '@/styles/tailwind.css'
|
||||
import 'focus-visible'
|
||||
|
||||
function usePrevious(value) {
|
||||
let ref = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default function App({ Component, pageProps, router }) {
|
||||
let previousPathname = usePrevious(router.pathname)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 flex justify-center sm:px-8">
|
||||
<div className="flex w-full max-w-7xl lg:px-8">
|
||||
<div className="w-full bg-white dark:bg-black dark:ring-zinc-300/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Header />
|
||||
<main>
|
||||
<Component previousPathname={previousPathname} {...pageProps} />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
76
src/pages/_document.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import {Head, Html, Main, NextScript} from 'next/document'
|
||||
|
||||
const modeScript = `
|
||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
updateMode()
|
||||
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
|
||||
window.addEventListener('storage', updateModeWithoutTransitions)
|
||||
|
||||
function updateMode() {
|
||||
let isSystemDarkMode = darkModeMediaQuery.matches
|
||||
let isDarkMode = window.localStorage.isDarkMode === 'true' || (!('isDarkMode' in window.localStorage) && isSystemDarkMode)
|
||||
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
if (isDarkMode === isSystemDarkMode) {
|
||||
delete window.localStorage.isDarkMode
|
||||
}
|
||||
}
|
||||
|
||||
function disableTransitionsTemporarily() {
|
||||
document.documentElement.classList.add('[&_*]:!transition-none')
|
||||
window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('[&_*]:!transition-none')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function updateModeWithoutTransitions() {
|
||||
disableTransitionsTemporarily()
|
||||
updateMode()
|
||||
}
|
||||
`
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html className="h-full antialiased" lang="en">
|
||||
<Head>
|
||||
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
href="/rss/feed.xml"
|
||||
/>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/feed+json"
|
||||
href="/rss/feed.json"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/static/icons/favicon-16x16.png"
|
||||
sizes="16x16"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/static/icons/favicon-32x32.png"
|
||||
sizes="32x32"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/static/icons/apple-touch-icon.png"
|
||||
/>
|
||||
</Head>
|
||||
<body className="flex h-full flex-col dark:bg-black">
|
||||
<Main/>
|
||||
<NextScript/>
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
146
src/pages/about.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
import Image from 'next/future/image'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import {Container} from '@/components/Container'
|
||||
import {
|
||||
GitHubIcon,
|
||||
LinkedInIcon,
|
||||
TwitterIcon
|
||||
} from '@/components/SocialIcons'
|
||||
import photoOfMeLg from '@/images/photo-of-me-lg.jpg'
|
||||
import awsCCPBadge from '@/images/aws-certified-cloud-practitioner-badge.png'
|
||||
|
||||
function SocialLink({className, href, children, icon: Icon}) {
|
||||
return (
|
||||
<li className={clsx(className, 'flex')}>
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex text-sm font-medium text-zinc-800 transition hover:text-indigo-500 dark:text-zinc-200 dark:hover:text-indigo-500"
|
||||
>
|
||||
<Icon className="h-6 w-6 flex-none fill-zinc-500 transition group-hover:fill-indigo-500"/>
|
||||
<span className="ml-4">{children}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function MailIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6 5a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3H6Zm.245 2.187a.75.75 0 0 0-.99 1.126l6.25 5.5a.75.75 0 0 0 .99 0l6.25-5.5a.75.75 0 0 0-.99-1.126L12 12.251 6.245 7.187Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>About - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="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={photoOfMeLg}
|
||||
alt=""
|
||||
sizes="(min-width: 1024px) 32rem, 20rem"
|
||||
className="aspect-square shadow-inner rounded-2xl bg-zinc-100 object-cover dark:bg-zinc-800 rotate-3"
|
||||
placeholder="blur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:order-first lg:row-span-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-blue-400 to-emerald-400">
|
||||
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="https://twitter.com/freemry" icon={TwitterIcon} className="mt-4">
|
||||
Follow on Twitter
|
||||
</SocialLink>
|
||||
<SocialLink
|
||||
href="mailto:hello@ryanfreeman.dev"
|
||||
icon={MailIcon}
|
||||
className="mt-8 border-t border-zinc-100 pt-8 dark:border-zinc-700/40"
|
||||
>
|
||||
hello@ryanfreeman.dev
|
||||
</SocialLink>
|
||||
</ul>
|
||||
<Image
|
||||
src={awsCCPBadge}
|
||||
width="170"
|
||||
height="170"
|
||||
alt="AWS Certified Cloud Practitioner"
|
||||
className="mt-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
36
src/pages/api/spotify/currently-playing.js
Normal file
@ -0,0 +1,36 @@
|
||||
import {getCurrentlyPlaying} from '@/lib/spotify'
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const response = await getCurrentlyPlaying()
|
||||
|
||||
if (response.status === 204 || response.status > 400) {
|
||||
return res.status(200).json({
|
||||
isPlaying: false
|
||||
})
|
||||
}
|
||||
|
||||
const song = await response.json()
|
||||
const {item} = song
|
||||
|
||||
if (item === null) {
|
||||
return res.status(200).json({
|
||||
isPlaying: false
|
||||
})
|
||||
}
|
||||
|
||||
const artist = item.artists.map(artist => artist.name).join(', ')
|
||||
const title = item.name;
|
||||
const songUrl = item.external_urls.spotify
|
||||
const album = item.album.name
|
||||
const albumImageUrl = item.album.images[0].url
|
||||
const isPlaying = song.is_playing;
|
||||
|
||||
return res.status(200).json({
|
||||
artist,
|
||||
title,
|
||||
songUrl,
|
||||
album,
|
||||
albumImageUrl,
|
||||
isPlaying
|
||||
})
|
||||
}
|
31
src/pages/api/spotify/last-played.js
Normal file
@ -0,0 +1,31 @@
|
||||
import {getRecentlyPlayed} from '@/lib/spotify'
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const response = await getRecentlyPlayed()
|
||||
|
||||
if (response.status > 400) {
|
||||
return res.status(200).json({})
|
||||
}
|
||||
|
||||
const tracks = await response.json();
|
||||
|
||||
if (tracks === null) {
|
||||
return res.status(200).json({})
|
||||
}
|
||||
|
||||
const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a)
|
||||
|
||||
const title = track.name;
|
||||
const artist = track.artists.map(artist => artist.name).join(', ')
|
||||
const songUrl = track.external_urls.spotify
|
||||
const album = track.album.name
|
||||
const albumImageUrl = track.album.images[0].url
|
||||
|
||||
return res.status(200).json({
|
||||
artist,
|
||||
title,
|
||||
songUrl,
|
||||
album,
|
||||
albumImageUrl
|
||||
})
|
||||
}
|
252
src/pages/index.jsx
Normal file
@ -0,0 +1,252 @@
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {Button} from '@/components/Button'
|
||||
import {Card} from '@/components/Card'
|
||||
import {Container} from '@/components/Container'
|
||||
import {
|
||||
GitHubIcon,
|
||||
LinkedInIcon,
|
||||
TwitterIcon
|
||||
} from '@/components/SocialIcons'
|
||||
import {generateRssFeed} from '@/lib/generateRssFeed'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
|
||||
function BriefcaseIcon(props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M2.75 9.75a3 3 0 0 1 3-3h12.5a3 3 0 0 1 3 3v8.5a3 3 0 0 1-3 3H5.75a3 3 0 0 1-3-3v-8.5Z"
|
||||
className="fill-zinc-100 stroke-zinc-400 dark:fill-zinc-100/10 dark:stroke-zinc-500"
|
||||
/>
|
||||
<path
|
||||
d="M3 14.25h6.249c.484 0 .952-.002 1.316.319l.777.682a.996.996 0 0 0 1.316 0l.777-.682c.364-.32.832-.319 1.316-.319H21M8.75 6.5V4.75a2 2 0 0 1 2-2h2.5a2 2 0 0 1 2 2V6.5"
|
||||
className="stroke-zinc-400 dark:stroke-zinc-500"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowDownIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M4.75 8.75 8 12.25m0 0 3.25-3.5M8 12.25v-8.5"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Article({article}) {
|
||||
return (
|
||||
<Card as="article">
|
||||
<Card.Title href={`/writing/${article.slug}`}>
|
||||
{article.title}
|
||||
</Card.Title>
|
||||
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
|
||||
{formatDate(article.date)}
|
||||
</Card.Eyebrow>
|
||||
<Card.Description>{article.description}</Card.Description>
|
||||
<Card.Cta>Read more</Card.Cta>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SocialLink({icon: Icon, ...props}) {
|
||||
return (
|
||||
<Link className="group -m-1 p-1" {...props}>
|
||||
<Icon
|
||||
className="h-6 w-6 fill-zinc-500 transition group-hover:fill-zinc-600 dark:fill-zinc-400 dark:group-hover:fill-zinc-300"/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Resume() {
|
||||
let work = [
|
||||
{
|
||||
company: 'Aer Lingus',
|
||||
title: 'Software engineer',
|
||||
start: '2022',
|
||||
end: {
|
||||
label: 'present',
|
||||
dateTime: new Date().getFullYear(),
|
||||
},
|
||||
},
|
||||
{
|
||||
company: 'Apple',
|
||||
title: 'At home advisor',
|
||||
start: '2014',
|
||||
end: '2018',
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40 -mt-6">
|
||||
<h2 className="flex text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<BriefcaseIcon className="h-6 w-6 flex-none"/>
|
||||
<span className="ml-3">Work</span>
|
||||
</h2>
|
||||
<ol className="mt-6 space-y-4">
|
||||
{work.map((role, roleIndex) => (
|
||||
<li key={roleIndex} className="flex gap-4">
|
||||
|
||||
<dl className="flex flex-auto flex-wrap gap-x-2">
|
||||
<dt className="sr-only">Company</dt>
|
||||
<dd className="w-full flex-none text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{role.company}
|
||||
</dd>
|
||||
<dt className="sr-only">Role</dt>
|
||||
<dd className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{role.title}
|
||||
</dd>
|
||||
<dt className="sr-only">Date</dt>
|
||||
<dd
|
||||
className="ml-auto text-xs text-zinc-500 dark:text-zinc-400"
|
||||
aria-label={`${role.start.label ?? role.start} until ${
|
||||
role.end.label ?? role.end
|
||||
}`}
|
||||
>
|
||||
<time dateTime={role.start.dateTime ?? role.start}>
|
||||
{role.start.label ?? role.start}
|
||||
</time>
|
||||
<span aria-hidden="true">–</span>
|
||||
<time dateTime={role.end.dateTime ?? role.end}>
|
||||
{role.end.label ?? role.end}
|
||||
</time>
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button href="/Ryan Freeman CV.pdf" variant="secondary" className="group mt-6 w-full">
|
||||
Download CV
|
||||
<ArrowDownIcon
|
||||
className="h-4 w-4 stroke-zinc-400 transition group-active:stroke-zinc-600 dark:group-hover:stroke-zinc-50 dark:group-active:stroke-zinc-50"/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home({articles}) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
Ryan Freeman - Full-stack software engineer from Dublin, Ireland.
|
||||
</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Full-stack software engineer who enjoys building cloud-native applications."
|
||||
/>
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
|
||||
/>
|
||||
<meta
|
||||
property="og:type"
|
||||
content="website"
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Full-stack software engineer who enjoys building cloud-native applications."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="/static/images/photo-of-me-lg.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:card"
|
||||
content="summary_large_image"/>
|
||||
<meta
|
||||
property="twitter:domain"
|
||||
content="ryanfreeman.dev"/>
|
||||
<meta
|
||||
property="twitter:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}`}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Ryan Freeman - Full-stack software engineer from Dublin, Ireland."/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Full-stack software engineer who enjoys building cloud-native applications."/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="/static/images/photo-of-me-lg.jpg"
|
||||
/>
|
||||
</Head>
|
||||
<Container className="mt-9">
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl bg-clip-text dark:text-transparent bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500">
|
||||
Full-stack software engineer who enjoys building cloud-native applications.
|
||||
</h1>
|
||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
||||
Hi. I'm Ryan, a software engineer based in Dublin, Ireland. I'm currently working in the
|
||||
aviation industry for Aer Lingus. I am passionate about personal growth and progressing in my career.
|
||||
This is my personal website where you can learn more about me, read
|
||||
articles I've written and see projects I've worked on.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-6">
|
||||
<SocialLink
|
||||
href="https://github.com/r-freeman"
|
||||
aria-label="Follow on GitHub"
|
||||
icon={GitHubIcon}
|
||||
/>
|
||||
<SocialLink
|
||||
href="https://linkedin.com/in/r-freeman/"
|
||||
aria-label="Follow on LinkedIn"
|
||||
icon={LinkedInIcon}
|
||||
/>
|
||||
<SocialLink
|
||||
href="https://twitter.com/freemry"
|
||||
aria-label="Follow on Twitter"
|
||||
icon={TwitterIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<Container className="mt-24 md:mt-28">
|
||||
<div className="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-16">
|
||||
{articles.map((article) => (
|
||||
<Article key={article.slug} article={article}/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-10 lg:pl-16 xl:pl-24">
|
||||
<Resume/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticProps() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await generateRssFeed()
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
articles: (await getAllArticles())
|
||||
.slice(0, 2)
|
||||
.map(({component, ...meta}) => meta),
|
||||
},
|
||||
}
|
||||
}
|
93
src/pages/projects.jsx
Normal file
@ -0,0 +1,93 @@
|
||||
import Head from 'next/head'
|
||||
|
||||
import {Card} from '@/components/Card'
|
||||
import {SimpleLayout} from '@/components/SimpleLayout'
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: 'Portfolio',
|
||||
description:
|
||||
'This is my personal website built using Tailwind and Next.js, it is a customised version of the Spotlight template from Tailwind UI.',
|
||||
link: {href: 'https://github.com/r-freeman/portfolio', label: 'r-freeman/portfolio'},
|
||||
},
|
||||
{
|
||||
name: 'Intellagent',
|
||||
description:
|
||||
'Intellagent is a cloud-based help desk software solution for small business, powered by AI and machine learning.',
|
||||
link: {href: 'https://github.com/r-freeman/intellagent-server', label: 'r-freeman/intellagent-server'},
|
||||
},
|
||||
{
|
||||
name: 'Super Mario Run',
|
||||
description:
|
||||
'Mobile game inspired by Super Mario Bros, built with JavaScript and Phaser.',
|
||||
link: {href: 'https://github.com/r-freeman/super-mario-run', label: 'r-freeman/super-mario-run'},
|
||||
},
|
||||
{
|
||||
name: 'ASOS Prototype',
|
||||
description:
|
||||
'Working prototype of ASOS\' website built in Axure RP 8.',
|
||||
link: {href: 'https://github.com/r-freeman/ASOS-Axure-Prototype', label: 'r-freeman/ASOS-Axure-Prototype'},
|
||||
},
|
||||
{
|
||||
name: 'College App',
|
||||
description:
|
||||
'Full-stack application built with Laravel 6, Vue.js and Tailwind CSS.',
|
||||
link: {href: 'https://github.com/r-freeman/college-app', label: 'r-freeman/college-app'},
|
||||
}
|
||||
]
|
||||
|
||||
function LinkIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M15.712 11.823a.75.75 0 1 0 1.06 1.06l-1.06-1.06Zm-4.95 1.768a.75.75 0 0 0 1.06-1.06l-1.06 1.06Zm-2.475-1.414a.75.75 0 1 0-1.06-1.06l1.06 1.06Zm4.95-1.768a.75.75 0 1 0-1.06 1.06l1.06-1.06Zm3.359.53-.884.884 1.06 1.06.885-.883-1.061-1.06Zm-4.95-2.12 1.414-1.415L12 6.344l-1.415 1.413 1.061 1.061Zm0 3.535a2.5 2.5 0 0 1 0-3.536l-1.06-1.06a4 4 0 0 0 0 5.656l1.06-1.06Zm4.95-4.95a2.5 2.5 0 0 1 0 3.535L17.656 12a4 4 0 0 0 0-5.657l-1.06 1.06Zm1.06-1.06a4 4 0 0 0-5.656 0l1.06 1.06a2.5 2.5 0 0 1 3.536 0l1.06-1.06Zm-7.07 7.07.176.177 1.06-1.06-.176-.177-1.06 1.06Zm-3.183-.353.884-.884-1.06-1.06-.884.883 1.06 1.06Zm4.95 2.121-1.414 1.414 1.06 1.06 1.415-1.413-1.06-1.061Zm0-3.536a2.5 2.5 0 0 1 0 3.536l1.06 1.06a4 4 0 0 0 0-5.656l-1.06 1.06Zm-4.95 4.95a2.5 2.5 0 0 1 0-3.535L6.344 12a4 4 0 0 0 0 5.656l1.06-1.06Zm-1.06 1.06a4 4 0 0 0 5.657 0l-1.061-1.06a2.5 2.5 0 0 1-3.535 0l-1.061 1.06Zm7.07-7.07-.176-.177-1.06 1.06.176.178 1.06-1.061Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Projects - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Things I've made and projects I've worked on."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Projects - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Things I've made and projects I've worked on."
|
||||
/>
|
||||
</Head>
|
||||
<SimpleLayout
|
||||
title="Things I've made and projects I've worked on."
|
||||
intro="Here's a selection of academic and personal projects that I have worked on. Many of them are open-source, so if you see something that piques your interest, check out the code and contribute if you have ideas for how it can be improved."
|
||||
gradient="bg-gradient-to-r from-sky-400 to-blue-500"
|
||||
>
|
||||
<ul
|
||||
role="list"
|
||||
className="grid grid-cols-1 gap-x-12 gap-y-16 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<Card as="li" key={project.name}>
|
||||
<h2 className="text-base font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<Card.Link href={project.link.href}>{project.name}</Card.Link>
|
||||
</h2>
|
||||
<Card.Description>{project.description}</Card.Description>
|
||||
<p className="relative z-10 mt-6 flex text-sm font-medium text-zinc-400 transition group-hover:text-indigo-500 dark:text-zinc-200">
|
||||
<LinkIcon className="h-6 w-6 flex-none"/>
|
||||
<span className="ml-2">{project.link.label}</span>
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
</SimpleLayout>
|
||||
</>
|
||||
)
|
||||
}
|
117
src/pages/uses.jsx
Normal file
@ -0,0 +1,117 @@
|
||||
import Head from 'next/head'
|
||||
|
||||
import {Card} from '@/components/Card'
|
||||
import {Section} from '@/components/Section'
|
||||
import {SimpleLayout} from '@/components/SimpleLayout'
|
||||
import Link from "next/link";
|
||||
|
||||
|
||||
function ToolsSection({children, ...props}) {
|
||||
return (
|
||||
<Section {...props}>
|
||||
<ul role="list" className="space-y-16">
|
||||
{children}
|
||||
</ul>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function Tool({title, href, children}) {
|
||||
return (
|
||||
<Card as="li">
|
||||
<Card.Title as="h3" href={href}>
|
||||
{title}
|
||||
</Card.Title>
|
||||
<Card.Description>{children}</Card.Description>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Uses() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Uses - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Software I use, equipment that makes my job easier, and other things I recommend."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Uses - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Software I use, equipment that makes my job easier, and other things I recommend."
|
||||
/>
|
||||
</Head>
|
||||
<SimpleLayout
|
||||
title="Software I use, equipment that makes my job easier, and other things I recommend."
|
||||
intro="I get asked a lot about the things I use to build software and stay productive. 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. This build is composed of some new and old
|
||||
parts which I have gathered over the years. Click here to see a comprehensive listing of all the parts
|
||||
used in this build on PCPartPicker.
|
||||
</Tool>
|
||||
<Tool title="Herman Miller Aeron Chair"
|
||||
href="https://hermanmiller.com/en_eur/products/seating/office-chairs/aeron-chairs/">
|
||||
I bought this chair second-hand when I started working for Apple, it'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="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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import {ArticleLayout} from '@/components/ArticleLayout'
|
||||
import Image from 'next/future/image'
|
||||
import forestForkingPath from './jens-lelie-u0vgcIOQG08-unsplash.jpg'
|
||||
|
||||
export const meta = {
|
||||
author: 'Ryan Freeman',
|
||||
date: '2022-12-04',
|
||||
title: 'A personal journey in software engineering',
|
||||
description:
|
||||
'Hello there! If you\'re reading this, you\'ve likely stumbled upon my website — welcome! My name is Ryan Freeman, and I\'m a full-stack developer with a passion for creating intuitive and dynamic web applications.',
|
||||
ogImage: `/static/images/jens-lelie-u0vgcIOQG08-unsplash.jpg`
|
||||
}
|
||||
|
||||
export default (props) => <ArticleLayout meta={meta} {...props} />
|
||||
|
||||
<Image
|
||||
src={forestForkingPath}
|
||||
alt="Photo by Jens Lelie on Unsplash"
|
||||
placeholder="blur"
|
||||
/>
|
||||
|
||||
Hello there!
|
||||
|
||||
If you're reading this, you've likely stumbled upon my website — welcome! My name is Ryan Freeman, and I'm a full-stack developer
|
||||
with a passion for creating intuitive and dynamic web applications.
|
||||
|
||||
I have been working in the aviation industry for Aer Lingus since February 2022. My specialities include React, Java
|
||||
and AWS technologies, and I have a strong focus on building scalable and reliable solutions.
|
||||
|
||||
This website is a showcase of my work, as well as a platform for me to share my thoughts and insights in the world of software engineering.
|
||||
Here's what you can expect to find:
|
||||
|
||||
- A portfolio of my past and current projects, showcasing the range of applications I have developed and the technologies I have used.
|
||||
- Blog posts on a variety of topics, from the latest trends in software engineering to my personal experiences and insights as a full-stack developer.
|
||||
- Links to connect with me and to download my most up-to-date CV.
|
||||
|
||||
I hope that my website provides you with a glimpse into my world as a full-stack developer, and I invite you to explore and learn more about what I do.
|
||||
|
||||
Thanks for visiting, and I hope to hear from you soon!
|
After Width: | Height: | Size: 830 KiB |
@ -0,0 +1,36 @@
|
||||
import {ArticleLayout} from '@/components/ArticleLayout'
|
||||
|
||||
export const meta = {
|
||||
author: 'Ryan Freeman',
|
||||
date: '2023-01-02',
|
||||
title: 'How to add TypeScript to an existing Next.js project',
|
||||
description: 'Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a tsconfig.json file in the project root with touch tsconfig.json.'
|
||||
}
|
||||
|
||||
export default (props) => <ArticleLayout meta={meta} {...props} />
|
||||
|
||||
# Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`.
|
||||
Next, run `next dev` and the _tsconfig.json_ file will be populated with the default values, you may customise this configuration to your liking. A file called _next-env.d.ts_ will be created at the project root, this file **should not** be removed or edited.
|
||||
|
||||
However, you should add an entry in your `.gitignore` for _next-env.d.ts_ so it is ignored by version control.
|
||||
|
||||
```json
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"additional.d.ts"
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
```
|
||||
|
||||
If you need to add additional types, create a _additional.d.ts_ file in your project root and reference it in the `include` array in _tsconfig.json_.
|
||||
|
||||
```json
|
||||
"strict": true
|
||||
```
|
||||
|
||||
For experienced TypeScript developers, you can enable `strict` mode by editing _tsconfig.json_, this setting is disabled by default.
|
||||
|
||||
That's it! Your Next.js project is ready to leverage the power of TypeScript.
|
||||
|
||||
|
78
src/pages/writing/index.jsx
Normal file
@ -0,0 +1,78 @@
|
||||
import Head from 'next/head'
|
||||
|
||||
import {Card} from '@/components/Card'
|
||||
import {SimpleLayout} from '@/components/SimpleLayout'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
|
||||
function Article({article}) {
|
||||
return (
|
||||
<article className="md:grid md:grid-cols-4 md:items-baseline">
|
||||
<Card className="md:col-span-3">
|
||||
<Card.Title href={`/writing/${article.slug}`}>
|
||||
{article.title}
|
||||
</Card.Title>
|
||||
<Card.Eyebrow
|
||||
as="time"
|
||||
dateTime={article.date}
|
||||
className="md:hidden"
|
||||
decorate={false}
|
||||
>
|
||||
{formatDate(article.date)}
|
||||
</Card.Eyebrow>
|
||||
<Card.Description>{article.description}</Card.Description>
|
||||
<Card.Cta>Read article</Card.Cta>
|
||||
</Card>
|
||||
<Card.Eyebrow
|
||||
as="time"
|
||||
dateTime={article.date}
|
||||
className="mt-1 hidden md:block"
|
||||
>
|
||||
{formatDate(article.date)}
|
||||
</Card.Eyebrow>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArticlesIndex({articles}) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Writing - Ryan Freeman</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Writing on software engineering, and everything in between."
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Writing - Ryan Freeman"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Writing on software engineering, and everything in between."
|
||||
/>
|
||||
</Head>
|
||||
<SimpleLayout
|
||||
title="Writing on software engineering, and everything in between."
|
||||
intro="All of my long-form thoughts on software engineering, and more, displayed in chronological order."
|
||||
gradient="bg-gradient-to-r from-pink-500 to-violet-500"
|
||||
>
|
||||
<div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
|
||||
<div className="flex max-w-3xl flex-col space-y-16">
|
||||
{articles.map((article) => (
|
||||
<Article key={article.slug} article={article}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SimpleLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticProps() {
|
||||
return {
|
||||
props: {
|
||||
articles: (await getAllArticles()).map(({component, ...meta}) => meta),
|
||||
},
|
||||
}
|
||||
}
|
47
src/styles/prism.css
Normal file
@ -0,0 +1,47 @@
|
||||
pre[class*='language-'] {
|
||||
color: theme('colors.zinc.100');
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.class-name,
|
||||
.token.selector,
|
||||
.token.selector .class,
|
||||
.token.selector.class,
|
||||
.token.function {
|
||||
color: theme('colors.pink.400');
|
||||
}
|
||||
|
||||
.token.attr-name,
|
||||
.token.keyword,
|
||||
.token.rule,
|
||||
.token.pseudo-class,
|
||||
.token.important {
|
||||
color: theme('colors.zinc.300');
|
||||
}
|
||||
|
||||
.token.module {
|
||||
color: theme('colors.pink.400');
|
||||
}
|
||||
|
||||
.token.attr-value,
|
||||
.token.class,
|
||||
.token.string,
|
||||
.token.property {
|
||||
color: theme('colors.teal.300');
|
||||
}
|
||||
|
||||
.token.punctuation,
|
||||
.token.attr-equals {
|
||||
color: theme('colors.zinc.500');
|
||||
}
|
||||
|
||||
.token.unit,
|
||||
.language-css .token.function {
|
||||
color: theme('colors.sky.200');
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.operator,
|
||||
.token.combinator {
|
||||
color: theme('colors.zinc.400');
|
||||
}
|
4
src/styles/tailwind.css
Normal file
@ -0,0 +1,4 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import './prism.css';
|
||||
@import 'tailwindcss/utilities';
|
@ -1,99 +0,0 @@
|
||||
@import "mixins.module";
|
||||
@import "utilities.module";
|
||||
|
||||
.container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
padding: 4rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.125;
|
||||
@include sm {
|
||||
font-size: 3rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
}
|
||||
|
||||
.title span {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: normal;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.bio {
|
||||
font-size: 1rem;
|
||||
line-height: 1.625;
|
||||
max-width: 24rem;
|
||||
@include lg {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 2rem 0 1rem 0;
|
||||
font-weight: normal;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.title, .description, .bio {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 2px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.625;
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
@import "variables.module";
|
||||
|
||||
@mixin sm {
|
||||
@media (min-width: #{$screen-sm-min}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin md {
|
||||
@media (min-width: #{$screen-md-min}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin lg {
|
||||
@media (min-width: #{$screen-lg-min}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin xl {
|
||||
@media (min-width: #{$screen-xl-min}) {
|
||||
@content;
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
$screen-sm-min: 640px;
|
||||
$screen-md-min: 768px;
|
||||
$screen-lg-min: 1024px;
|
||||
$screen-xl-min: 1280px;
|
313
tailwind.config.js
Normal file
@ -0,0 +1,313 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{js,jsx}'],
|
||||
darkMode: 'class',
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/line-clamp')
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
green: {
|
||||
950: '#1fdf64'
|
||||
}
|
||||
}
|
||||
},
|
||||
fontSize: {
|
||||
xs: ['0.8125rem', {lineHeight: '1.5rem'}],
|
||||
sm: ['0.875rem', {lineHeight: '1.5rem'}],
|
||||
base: ['1rem', {lineHeight: '1.75rem'}],
|
||||
lg: ['1.125rem', {lineHeight: '1.75rem'}],
|
||||
xl: ['1.25rem', {lineHeight: '2rem'}],
|
||||
'2xl': ['1.5rem', {lineHeight: '2rem'}],
|
||||
'3xl': ['1.875rem', {lineHeight: '2.25rem'}],
|
||||
'4xl': ['2rem', {lineHeight: '2.5rem'}],
|
||||
'5xl': ['3rem', {lineHeight: '3.5rem'}],
|
||||
'6xl': ['3.75rem', {lineHeight: '1'}],
|
||||
'7xl': ['4.5rem', {lineHeight: '1'}],
|
||||
'8xl': ['6rem', {lineHeight: '1'}],
|
||||
'9xl': ['8rem', {lineHeight: '1'}],
|
||||
},
|
||||
typography: (theme) => ({
|
||||
invert: {
|
||||
css: {
|
||||
'--tw-prose-body': 'var(--tw-prose-invert-body)',
|
||||
'--tw-prose-headings': 'var(--tw-prose-invert-headings)',
|
||||
'--tw-prose-links': 'var(--tw-prose-invert-links)',
|
||||
'--tw-prose-links-hover': 'var(--tw-prose-invert-links-hover)',
|
||||
'--tw-prose-underline': 'var(--tw-prose-invert-underline)',
|
||||
'--tw-prose-underline-hover':
|
||||
'var(--tw-prose-invert-underline-hover)',
|
||||
'--tw-prose-bold': 'var(--tw-prose-invert-bold)',
|
||||
'--tw-prose-counters': 'var(--tw-prose-invert-counters)',
|
||||
'--tw-prose-bullets': 'var(--tw-prose-invert-bullets)',
|
||||
'--tw-prose-hr': 'var(--tw-prose-invert-hr)',
|
||||
'--tw-prose-quote-borders': 'var(--tw-prose-invert-quote-borders)',
|
||||
'--tw-prose-captions': 'var(--tw-prose-invert-captions)',
|
||||
'--tw-prose-code': 'var(--tw-prose-invert-code)',
|
||||
'--tw-prose-code-bg': 'var(--tw-prose-invert-code-bg)',
|
||||
'--tw-prose-pre-code': 'var(--tw-prose-invert-pre-code)',
|
||||
'--tw-prose-pre-bg': 'var(--tw-prose-invert-pre-bg)',
|
||||
'--tw-prose-pre-border': 'var(--tw-prose-invert-pre-border)',
|
||||
'--tw-prose-th-borders': 'var(--tw-prose-invert-th-borders)',
|
||||
'--tw-prose-td-borders': 'var(--tw-prose-invert-td-borders)',
|
||||
},
|
||||
},
|
||||
DEFAULT: {
|
||||
css: {
|
||||
'--tw-prose-body': theme('colors.zinc.600'),
|
||||
'--tw-prose-headings': theme('colors.zinc.900'),
|
||||
'--tw-prose-links': theme('colors.indigo.500'),
|
||||
'--tw-prose-links-hover': theme('colors.indigo.600'),
|
||||
'--tw-prose-underline': theme('colors.teal.500 / 0.2'),
|
||||
'--tw-prose-underline-hover': theme('colors.indigo.500'),
|
||||
'--tw-prose-bold': theme('colors.zinc.900'),
|
||||
'--tw-prose-counters': theme('colors.zinc.900'),
|
||||
'--tw-prose-bullets': theme('colors.zinc.900'),
|
||||
'--tw-prose-hr': theme('colors.zinc.100'),
|
||||
'--tw-prose-quote-borders': theme('colors.zinc.200'),
|
||||
'--tw-prose-captions': theme('colors.zinc.400'),
|
||||
'--tw-prose-code': theme('colors.zinc.700'),
|
||||
'--tw-prose-code-bg': theme('colors.zinc.300 / 0.2'),
|
||||
'--tw-prose-pre-code': theme('colors.zinc.100'),
|
||||
'--tw-prose-pre-bg': theme('colors.zinc.900'),
|
||||
'--tw-prose-pre-border': 'transparent',
|
||||
'--tw-prose-th-borders': theme('colors.zinc.200'),
|
||||
'--tw-prose-td-borders': theme('colors.zinc.100'),
|
||||
|
||||
'--tw-prose-invert-body': theme('colors.zinc.400'),
|
||||
'--tw-prose-invert-headings': theme('colors.zinc.200'),
|
||||
'--tw-prose-invert-links': theme('colors.indigo.400'),
|
||||
'--tw-prose-invert-links-hover': theme('colors.indigo.400'),
|
||||
'--tw-prose-invert-underline': theme('colors.indigo.400 / 0.3'),
|
||||
'--tw-prose-invert-underline-hover': theme('colors.indigo.400'),
|
||||
'--tw-prose-invert-bold': theme('colors.zinc.200'),
|
||||
'--tw-prose-invert-counters': theme('colors.zinc.200'),
|
||||
'--tw-prose-invert-bullets': theme('colors.zinc.200'),
|
||||
'--tw-prose-invert-hr': theme('colors.zinc.700 / 0.4'),
|
||||
'--tw-prose-invert-quote-borders': theme('colors.zinc.500'),
|
||||
'--tw-prose-invert-captions': theme('colors.zinc.500'),
|
||||
'--tw-prose-invert-code': theme('colors.zinc.300'),
|
||||
'--tw-prose-invert-code-bg': theme('colors.zinc.200 / 0.05'),
|
||||
'--tw-prose-invert-pre-code': theme('colors.zinc.100'),
|
||||
'--tw-prose-invert-pre-bg': 'rgb(0 0 0 / 0.4)',
|
||||
'--tw-prose-invert-pre-border': theme('colors.zinc.200 / 0.1'),
|
||||
'--tw-prose-invert-th-borders': theme('colors.zinc.700'),
|
||||
'--tw-prose-invert-td-borders': theme('colors.zinc.800'),
|
||||
|
||||
// Base
|
||||
color: 'var(--tw-prose-body)',
|
||||
lineHeight: theme('lineHeight.7'),
|
||||
'> *': {
|
||||
marginTop: theme('spacing.10'),
|
||||
marginBottom: theme('spacing.10'),
|
||||
},
|
||||
p: {
|
||||
marginTop: theme('spacing.7'),
|
||||
marginBottom: theme('spacing.7'),
|
||||
},
|
||||
|
||||
// Headings
|
||||
'h2, h3': {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: theme('fontWeight.semibold'),
|
||||
},
|
||||
h2: {
|
||||
fontSize: theme('fontSize.xl')[0],
|
||||
lineHeight: theme('lineHeight.7'),
|
||||
marginTop: theme('spacing.20'),
|
||||
marginBottom: theme('spacing.4'),
|
||||
},
|
||||
h3: {
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
lineHeight: theme('lineHeight.7'),
|
||||
marginTop: theme('spacing.16'),
|
||||
marginBottom: theme('spacing.4'),
|
||||
},
|
||||
':is(h2, h3) + *': {
|
||||
marginTop: 0,
|
||||
},
|
||||
|
||||
// Images
|
||||
img: {
|
||||
borderRadius: theme('borderRadius.3xl'),
|
||||
},
|
||||
|
||||
// Inline elements
|
||||
a: {
|
||||
color: 'var(--tw-prose-links)',
|
||||
fontWeight: theme('fontWeight.semibold'),
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: 'var(--tw-prose-underline)',
|
||||
transitionProperty: 'color, text-decoration-color',
|
||||
transitionDuration: theme('transitionDuration.150'),
|
||||
transitionTimingFunction: theme('transitionTimingFunction.in-out'),
|
||||
},
|
||||
'a:hover': {
|
||||
color: 'var(--tw-prose-links-hover)',
|
||||
textDecorationColor: 'var(--tw-prose-underline-hover)',
|
||||
},
|
||||
strong: {
|
||||
color: 'var(--tw-prose-bold)',
|
||||
fontWeight: theme('fontWeight.semibold'),
|
||||
},
|
||||
code: {
|
||||
display: 'inline-block',
|
||||
color: 'var(--tw-prose-code)',
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
fontWeight: theme('fontWeight.semibold'),
|
||||
backgroundColor: 'var(--tw-prose-code-bg)',
|
||||
borderRadius: theme('borderRadius.lg'),
|
||||
paddingLeft: theme('spacing.1'),
|
||||
paddingRight: theme('spacing.1'),
|
||||
},
|
||||
'a code': {
|
||||
color: 'inherit',
|
||||
},
|
||||
':is(h2, h3) code': {
|
||||
fontWeight: theme('fontWeight.bold'),
|
||||
},
|
||||
|
||||
// Quotes
|
||||
blockquote: {
|
||||
paddingLeft: theme('spacing.6'),
|
||||
borderLeftWidth: theme('borderWidth.2'),
|
||||
borderLeftColor: 'var(--tw-prose-quote-borders)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Figures
|
||||
figcaption: {
|
||||
color: 'var(--tw-prose-captions)',
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
lineHeight: theme('lineHeight.6'),
|
||||
marginTop: theme('spacing.3'),
|
||||
},
|
||||
'figcaption > p': {
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
// Lists
|
||||
ul: {
|
||||
listStyleType: 'disc',
|
||||
},
|
||||
ol: {
|
||||
listStyleType: 'decimal',
|
||||
},
|
||||
'ul, ol': {
|
||||
paddingLeft: theme('spacing.6'),
|
||||
},
|
||||
li: {
|
||||
marginTop: theme('spacing.6'),
|
||||
marginBottom: theme('spacing.6'),
|
||||
paddingLeft: theme('spacing[3.5]'),
|
||||
},
|
||||
'li::marker': {
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
fontWeight: theme('fontWeight.semibold'),
|
||||
},
|
||||
'ol > li::marker': {
|
||||
color: 'var(--tw-prose-counters)',
|
||||
},
|
||||
'ul > li::marker': {
|
||||
color: 'var(--tw-prose-bullets)',
|
||||
},
|
||||
'li :is(ol, ul)': {
|
||||
marginTop: theme('spacing.4'),
|
||||
marginBottom: theme('spacing.4'),
|
||||
},
|
||||
'li :is(li, p)': {
|
||||
marginTop: theme('spacing.3'),
|
||||
marginBottom: theme('spacing.3'),
|
||||
},
|
||||
|
||||
// Code blocks
|
||||
pre: {
|
||||
color: 'var(--tw-prose-pre-code)',
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
fontWeight: theme('fontWeight.medium'),
|
||||
backgroundColor: 'var(--tw-prose-pre-bg)',
|
||||
borderRadius: theme('borderRadius.3xl'),
|
||||
padding: theme('spacing.8'),
|
||||
overflowX: 'auto',
|
||||
border: '1px solid',
|
||||
borderColor: 'var(--tw-prose-pre-border)',
|
||||
},
|
||||
'pre code': {
|
||||
display: 'inline',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontWeight: 'inherit',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
padding: 0,
|
||||
},
|
||||
|
||||
// Horizontal rules
|
||||
hr: {
|
||||
marginTop: theme('spacing.20'),
|
||||
marginBottom: theme('spacing.20'),
|
||||
borderTopWidth: '1px',
|
||||
borderColor: 'var(--tw-prose-hr)',
|
||||
'@screen lg': {
|
||||
marginLeft: `calc(${theme('spacing.12')} * -1)`,
|
||||
marginRight: `calc(${theme('spacing.12')} * -1)`,
|
||||
},
|
||||
},
|
||||
|
||||
// Tables
|
||||
table: {
|
||||
width: '100%',
|
||||
tableLayout: 'auto',
|
||||
textAlign: 'left',
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
},
|
||||
thead: {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'var(--tw-prose-th-borders)',
|
||||
},
|
||||
'thead th': {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: theme('fontWeight.semibold'),
|
||||
verticalAlign: 'bottom',
|
||||
paddingBottom: theme('spacing.2'),
|
||||
},
|
||||
'thead th:not(:first-child)': {
|
||||
paddingLeft: theme('spacing.2'),
|
||||
},
|
||||
'thead th:not(:last-child)': {
|
||||
paddingRight: theme('spacing.2'),
|
||||
},
|
||||
'tbody tr': {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'var(--tw-prose-td-borders)',
|
||||
},
|
||||
'tbody tr:last-child': {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
'tbody td': {
|
||||
verticalAlign: 'baseline',
|
||||
},
|
||||
tfoot: {
|
||||
borderTopWidth: '1px',
|
||||
borderTopColor: 'var(--tw-prose-th-borders)',
|
||||
},
|
||||
'tfoot td': {
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
':is(tbody, tfoot) td': {
|
||||
paddingTop: theme('spacing.2'),
|
||||
paddingBottom: theme('spacing.2'),
|
||||
},
|
||||
':is(tbody, tfoot) td:not(:first-child)': {
|
||||
paddingLeft: theme('spacing.2'),
|
||||
},
|
||||
':is(tbody, tfoot) td:not(:last-child)': {
|
||||
paddingRight: theme('spacing.2'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|