mirror of
https://github.com/r-freeman/portfolio.git
synced 2024-11-24 15:15:41 +00:00
Migrated to nextjs13 & TypeScript
This commit is contained in:
parent
8edf916fba
commit
2a11b29962
12
.gitignore
vendored
12
.gitignore
vendored
@ -1,5 +1,11 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
public/rss
|
||||||
|
public/sitemap.xml
|
||||||
|
public/robots.txt
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
@ -31,6 +37,6 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# generated files
|
# typescript
|
||||||
/public/rss/
|
*.tsbuildinfo
|
||||||
.idea/
|
next-env.d.ts
|
||||||
|
15
README.md
15
README.md
@ -3,9 +3,14 @@
|
|||||||
This website was built using React, Next.js and Tailwind CSS. It is designed to showcase my professional experience and skills, as
|
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.
|
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
|
## Features
|
||||||
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
|
- 📝 TypeScript.
|
||||||
together.
|
- 💨 Styled with Tailwind CSS.
|
||||||
|
- 🌙Toggleable dark/light mode.
|
||||||
|
- ✍️ Personal blog with MDX (JSX in Markdown).
|
||||||
|
- 🎵 Shows what I'm listening to on Spotify.
|
||||||
|
- 🤖 Automated RSS feed and sitemap generation.
|
||||||
|
|
||||||
|
Additional features will be added in the future. Feel free to explore the different sections, and contact me if you have any
|
||||||
|
questions or would like to work together.
|
BIN
images/avatar.jpg
Normal file
BIN
images/avatar.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 291 KiB |
@ -2,7 +2,9 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
/** @type {import('next-sitemap').IConfig} */
|
|
||||||
module.exports = {
|
|
||||||
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com',
|
|
||||||
generateRobotsTxt: true,
|
|
||||||
generateIndexSitemap: false
|
|
||||||
}
|
|
@ -4,12 +4,12 @@ import rehypePrism from '@mapbox/rehype-prism'
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
pageExtensions: ['jsx', 'js', 'mdx'],
|
pageExtensions: ['jsx', 'js', 'tsx', 'mdx'],
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
experimental: {
|
experimental: {
|
||||||
newNextLinkBehavior: true,
|
newNextLinkBehavior: true,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
domains: ['i.scdn.co']
|
domains: ['i.scdn.co']
|
||||||
@ -29,7 +29,7 @@ const withMDX = nextMDX({
|
|||||||
options: {
|
options: {
|
||||||
remarkPlugins: [remarkGfm],
|
remarkPlugins: [remarkGfm],
|
||||||
rehypePlugins: [rehypePrism],
|
rehypePlugins: [rehypePrism],
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default withMDX(nextConfig)
|
export default withMDX(nextConfig)
|
||||||
|
5988
package-lock.json
generated
5988
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
45
package.json
45
package.json
@ -1,41 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwindui-template",
|
"name": "portfolio",
|
||||||
|
"author": "Ryan Freeman",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build && next-sitemap",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"browserslist": "defaults, not ie <= 11",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.0",
|
"@headlessui/react": "^1.7.7",
|
||||||
"@mapbox/rehype-prism": "^0.8.0",
|
"@mapbox/rehype-prism": "^0.8.0",
|
||||||
"@mdx-js/loader": "^2.1.2",
|
"@next/mdx": "^13.1.1",
|
||||||
"@next/mdx": "^12.2.3",
|
"@tailwindcss/line-clamp": "^0.4.2",
|
||||||
"@tailwindcss/typography": "^0.5.4",
|
"@tailwindcss/typography": "^0.5.8",
|
||||||
"autoprefixer": "^10.4.7",
|
"@types/mdx": "^2.0.3",
|
||||||
"clsx": "^1.2.0",
|
"@types/node": "18.11.18",
|
||||||
"fast-glob": "^3.2.11",
|
"@types/react": "18.0.26",
|
||||||
|
"@types/react-dom": "18.0.10",
|
||||||
|
"autoprefixer": "^10.4.13",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"eslint": "8.31.0",
|
||||||
|
"eslint-config-next": "13.1.1",
|
||||||
|
"fast-glob": "^3.2.12",
|
||||||
"feed": "^4.2.2",
|
"feed": "^4.2.2",
|
||||||
"focus-visible": "^5.2.0",
|
"focus-visible": "^5.2.0",
|
||||||
"motion": "^10.15.3",
|
"motion": "^10.15.5",
|
||||||
"next": "^12.3.0",
|
"next": "13.1.2",
|
||||||
"next-sitemap": "^3.1.44",
|
|
||||||
"node-fetch": "^3.3.0",
|
"node-fetch": "^3.3.0",
|
||||||
"postcss-focus-visible": "^6.0.4",
|
"postcss": "^8.4.21",
|
||||||
|
"postcss-focus-visible": "^7.1.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
|
"sharp": "^0.31.3",
|
||||||
"swr": "^2.0.0",
|
"swr": "^2.0.0",
|
||||||
"tailwindcss": "^3.1.4"
|
"tailwindcss": "^3.2.4",
|
||||||
},
|
"typescript": "4.9.4"
|
||||||
"devDependencies": {
|
|
||||||
"@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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,5 +5,5 @@ module.exports = {
|
|||||||
replaceWith: '[data-focus-visible-added]',
|
replaceWith: '[data-focus-visible-added]',
|
||||||
},
|
},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
singleQuote: true,
|
|
||||||
semi: false,
|
|
||||||
plugins: [require('prettier-plugin-tailwindcss')],
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
User-Agent: *
|
|
||||||
Allow: /
|
|
||||||
Sitemap: https://ryanfreeman.dev/sitemap.xml
|
|
@ -1,50 +0,0 @@
|
|||||||
<?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
|
|
||||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
|
||||||
</urlset>
|
|
Binary file not shown.
Before Width: | Height: | Size: 319 KiB |
@ -1,30 +1,27 @@
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import {useRouter} from 'next/router'
|
import {usePathname} from 'next/navigation'
|
||||||
|
import {Container} from './Container'
|
||||||
import {Container} from '@/components/Container'
|
|
||||||
import {formatDate} from '@/lib/formatDate'
|
import {formatDate} from '@/lib/formatDate'
|
||||||
import {Prose} from '@/components/Prose'
|
import {Prose} from './Prose'
|
||||||
|
import {ReactNode} from 'react'
|
||||||
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({
|
export function ArticleLayout({
|
||||||
children,
|
children,
|
||||||
meta,
|
|
||||||
isRssFeed = false,
|
isRssFeed = false,
|
||||||
previousPathname,
|
title,
|
||||||
|
description,
|
||||||
|
ogImage,
|
||||||
|
date
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
children?: ReactNode,
|
||||||
|
isRssFeed: boolean,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
ogImage: string,
|
||||||
|
date: string
|
||||||
}) {
|
}) {
|
||||||
let router = useRouter()
|
const pathname = usePathname()
|
||||||
|
|
||||||
if (isRssFeed) {
|
if (isRssFeed) {
|
||||||
return children
|
return children
|
||||||
@ -33,11 +30,11 @@ export function ArticleLayout({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{`${meta.title} - Ryan Freeman`}</title>
|
<title>{`${title} - Ryan Freeman`}</title>
|
||||||
<meta name="description" content={meta.description}/>
|
<meta name="description" content={description}/>
|
||||||
<meta
|
<meta
|
||||||
property="og:url"
|
property="og:url"
|
||||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
|
content={`${process.env.NEXT_PUBLIC_SITE_URL}${pathname}`}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="og:type"
|
property="og:type"
|
||||||
@ -45,17 +42,17 @@ export function ArticleLayout({
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="og:title"
|
property="og:title"
|
||||||
content={meta.title}
|
content={title}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content={meta.description}
|
content={description}
|
||||||
/>
|
/>
|
||||||
{meta.ogImage &&
|
{ogImage &&
|
||||||
<>
|
<>
|
||||||
<meta
|
<meta
|
||||||
property="og:image"
|
property="og:image"
|
||||||
content={meta.ogImage}
|
content={ogImage}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="twitter:card"
|
name="twitter:card"
|
||||||
@ -63,7 +60,7 @@ export function ArticleLayout({
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="twitter:image"
|
name="twitter:image"
|
||||||
content={meta.ogImage}
|
content={ogImage}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -73,41 +70,30 @@ export function ArticleLayout({
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="twitter:url"
|
property="twitter:url"
|
||||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
|
content={`${process.env.NEXT_PUBLIC_SITE_URL}${pathname}`}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="twitter:title"
|
name="twitter:title"
|
||||||
content={meta.title}
|
content={title}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content={meta.description}
|
content={description}
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
<Container className="mt-16 lg:mt-32">
|
<Container className="mt-16 lg:mt-32">
|
||||||
<div className="xl:relative">
|
<div className="xl:relative">
|
||||||
<div className="mx-auto max-w-2xl">
|
<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>
|
<article>
|
||||||
<header className="flex flex-col">
|
<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">
|
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||||
{meta.title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<time
|
<time
|
||||||
dateTime={meta.date}
|
dateTime={date}
|
||||||
className="order-first flex items-center text-base text-zinc-500 dark:text-zinc-400"
|
className="order-first flex items-center text-base text-zinc-500 dark:text-zinc-400"
|
||||||
>
|
>
|
||||||
<span>{formatDate(meta.date)}</span>
|
<span>{formatDate(date)}</span>
|
||||||
</time>
|
</time>
|
||||||
</header>
|
</header>
|
||||||
<Prose className="mt-8">{children}</Prose>
|
<Prose className="mt-8">{children}</Prose>
|
@ -1,23 +0,0 @@
|
|||||||
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} />
|
|
||||||
)
|
|
||||||
}
|
|
36
src/components/Button.tsx
Normal file
36
src/components/Button.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {ReactNode} from 'react'
|
||||||
|
|
||||||
|
type VariantStyles = {
|
||||||
|
primary: string
|
||||||
|
secondary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Button = {
|
||||||
|
variant?: string
|
||||||
|
className: string
|
||||||
|
href: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: 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}: Button) {
|
||||||
|
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 as keyof VariantStyles],
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
return href ? (
|
||||||
|
<Link href={href} className={className} {...props} />
|
||||||
|
) : (
|
||||||
|
<button className={className} {...props} />
|
||||||
|
)
|
||||||
|
}
|
@ -1,7 +1,43 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import {ElementType, ReactNode} from 'react'
|
||||||
|
import {Props} from 'types'
|
||||||
|
|
||||||
function ChevronRightIcon(props) {
|
type Card = {
|
||||||
|
as?: ElementType
|
||||||
|
className?: string
|
||||||
|
small?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardLink = {
|
||||||
|
href: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardTitle = {
|
||||||
|
as?: ElementType
|
||||||
|
href: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardDescription = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardCta = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardEyebrow = {
|
||||||
|
as: ElementType
|
||||||
|
dateTime: string
|
||||||
|
decorate: boolean
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronRightIcon(props: Props) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||||
<path
|
<path
|
||||||
@ -14,7 +50,12 @@ function ChevronRightIcon(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({as: Component = 'div', className, small, children}) {
|
export function Card({
|
||||||
|
as: Component = 'div',
|
||||||
|
className,
|
||||||
|
small,
|
||||||
|
children
|
||||||
|
}: Card) {
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={clsx(className, 'group relative flex flex-col items-baseline', small && 'md:flex-row justify-between')}
|
className={clsx(className, 'group relative flex flex-col items-baseline', small && 'md:flex-row justify-between')}
|
||||||
@ -24,12 +65,12 @@ export function Card({as: Component = 'div', className, small, children}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card.Link = function CardLink({children, ...props}) {
|
Card.Link = function CardLink({href, children}: CardLink) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<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"/>
|
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}>
|
<Link href={href}>
|
||||||
<span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl"/>
|
<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>
|
<span className="relative z-10">{children}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@ -37,7 +78,7 @@ Card.Link = function CardLink({children, ...props}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card.Title = function CardTitle({as: Component = 'h2', href, children}) {
|
Card.Title = function CardTitle({as: Component = 'h2', href, children}: CardTitle) {
|
||||||
return (
|
return (
|
||||||
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
|
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
|
||||||
@ -45,7 +86,7 @@ Card.Title = function CardTitle({as: Component = 'h2', href, children}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card.Description = function CardDescription({children}) {
|
Card.Description = function CardDescription({children}: CardDescription) {
|
||||||
return (
|
return (
|
||||||
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
{children}
|
{children}
|
||||||
@ -53,7 +94,7 @@ Card.Description = function CardDescription({children}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card.Cta = function CardCta({children}) {
|
Card.Cta = function CardCta({children}: CardCta) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -71,7 +112,7 @@ Card.Eyebrow = function CardEyebrow({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) {
|
}: CardEyebrow) {
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={clsx(
|
className={clsx(
|
@ -1,42 +0,0 @@
|
|||||||
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
|
|
45
src/components/Container.tsx
Normal file
45
src/components/Container.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {forwardRef, ReactNode} from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type Container = {
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
style?: Object
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OuterContainer = forwardRef<HTMLDivElement, Container>(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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const InnerContainer = forwardRef<HTMLDivElement, Container>(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<HTMLDivElement, Container>(function Container(
|
||||||
|
{children, ...props},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<OuterContainer ref={ref} {...props}>
|
||||||
|
<InnerContainer>{children}</InnerContainer>
|
||||||
|
</OuterContainer>
|
||||||
|
)
|
||||||
|
})
|
@ -1,9 +1,10 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import {ReactNode} from 'react'
|
||||||
|
|
||||||
import {Container} from '@/components/Container'
|
import {OuterContainer, InnerContainer} from './Container'
|
||||||
import {SpotifyPlayer} from '@/components/SpotifyPlayer'
|
import {SpotifyPlayer} from './SpotifyPlayer'
|
||||||
|
|
||||||
function NavLink({href, children}) {
|
function NavLink({href, children}: { href: string, children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
@ -17,9 +18,9 @@ function NavLink({href, children}) {
|
|||||||
export function Footer() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="mt-32">
|
<footer className="mt-32">
|
||||||
<Container.Outer>
|
<OuterContainer>
|
||||||
<div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
|
<div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
|
||||||
<Container.Inner>
|
<InnerContainer>
|
||||||
<SpotifyPlayer/>
|
<SpotifyPlayer/>
|
||||||
<div className="flex flex-col items-center justify-between gap-6 mt-12">
|
<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">
|
<div className="flex gap-6 text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
@ -29,9 +30,9 @@ export function Footer() {
|
|||||||
<NavLink href="/uses">Uses</NavLink>
|
<NavLink href="/uses">Uses</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container.Inner>
|
</InnerContainer>
|
||||||
</div>
|
</div>
|
||||||
</Container.Outer>
|
</OuterContainer>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1,14 +1,16 @@
|
|||||||
import Image from 'next/future/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {useRouter} from 'next/router'
|
import {usePathname} from 'next/navigation'
|
||||||
|
import {Fragment, useEffect, useRef} from 'react'
|
||||||
import {Popover, Transition} from '@headlessui/react'
|
import {Popover, Transition} from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import {Container} from '@/components/Container'
|
import {Container} from './Container'
|
||||||
import avatar from '@/images/avatar.jpg'
|
import avatar from '@/images/avatar.jpg'
|
||||||
import {Fragment, useEffect, useRef} from 'react'
|
|
||||||
|
|
||||||
function CloseIcon(props) {
|
import type {Props} from 'types'
|
||||||
|
|
||||||
|
function CloseIcon(props: Props) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||||
<path
|
<path
|
||||||
@ -23,7 +25,7 @@ function CloseIcon(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChevronDownIcon(props) {
|
function ChevronDownIcon(props: Props) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
|
||||||
<path
|
<path
|
||||||
@ -37,7 +39,7 @@ function ChevronDownIcon(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SunIcon(props) {
|
function SunIcon(props: Props) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -57,7 +59,7 @@ function SunIcon(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoonIcon(props) {
|
function MoonIcon(props: Props) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||||
<path
|
<path
|
||||||
@ -70,7 +72,7 @@ function MoonIcon(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileNavItem({href, children}) {
|
function MobileNavItem({href, children}: { href: string } & Props) {
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<Popover.Button as={Link} href={href} className="block py-2">
|
<Popover.Button as={Link} href={href} className="block py-2">
|
||||||
@ -80,7 +82,7 @@ function MobileNavItem({href, children}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileNavigation(props) {
|
function MobileNavigation(props: Props) {
|
||||||
return (
|
return (
|
||||||
<Popover {...props}>
|
<Popover {...props}>
|
||||||
<Popover.Button
|
<Popover.Button
|
||||||
@ -137,8 +139,8 @@ function MobileNavigation(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({href, children}) {
|
function NavItem({href, children}: { href: string } & Props) {
|
||||||
let isActive = useRouter().pathname === href
|
let isActive = usePathname() === href
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
@ -161,7 +163,7 @@ function NavItem({href, children}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopNavigation(props) {
|
function DesktopNavigation(props: Props) {
|
||||||
return (
|
return (
|
||||||
<nav {...props}>
|
<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">
|
<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">
|
||||||
@ -211,13 +213,13 @@ function ModeToggle() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(number, a, b) {
|
function clamp(num: number, a: number, b: number) {
|
||||||
let min = Math.min(a, b)
|
let min = Math.min(a, b)
|
||||||
let max = Math.max(a, b)
|
let max = Math.max(a, b)
|
||||||
return Math.min(Math.max(number, min), max)
|
return Math.min(Math.max(num, min), max)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarContainer({className, ...props}) {
|
function AvatarContainer({className, ...props}: { style?: Object } & Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -229,7 +231,7 @@ function AvatarContainer({className, ...props}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Avatar({large = false, className, ...props}) {
|
function Avatar({large = false, className, ...props}: { large?: boolean, style?: Object } & Props) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
@ -253,26 +255,36 @@ function Avatar({large = false, className, ...props}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
let isHomePage = useRouter().pathname === '/'
|
const isHomePage = usePathname() === '/'
|
||||||
|
|
||||||
let headerRef = useRef()
|
const headerRef = useRef<HTMLDivElement>(null)
|
||||||
let avatarRef = useRef()
|
const avatarRef = useRef<HTMLImageElement>(null)
|
||||||
let isInitial = useRef(true)
|
const isInitial = useRef(true)
|
||||||
|
|
||||||
|
const headerPosition: Object = {
|
||||||
|
position: 'var(--header-position)'
|
||||||
|
}
|
||||||
|
const headerInnerPosition: Object = {
|
||||||
|
position: 'var(--header-inner-position)'
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let downDelay = avatarRef.current?.offsetTop ?? 0
|
let downDelay = avatarRef.current?.offsetTop ?? 0
|
||||||
let upDelay = 64
|
let upDelay = 64
|
||||||
|
|
||||||
function setProperty(property, value) {
|
function setProperty(property: string, value: string) {
|
||||||
document.documentElement.style.setProperty(property, value)
|
document.documentElement.style.setProperty(property, value.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeProperty(property) {
|
function removeProperty(property: string) {
|
||||||
document.documentElement.style.removeProperty(property)
|
document.documentElement.style.removeProperty(property)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeaderStyles() {
|
function updateHeaderStyles() {
|
||||||
let {top, height} = headerRef.current.getBoundingClientRect()
|
const headerBoundingRect = headerRef.current?.getBoundingClientRect()
|
||||||
|
let top = headerBoundingRect?.top!
|
||||||
|
let height = headerBoundingRect?.height!
|
||||||
|
|
||||||
let scrollY = clamp(
|
let scrollY = clamp(
|
||||||
window.scrollY,
|
window.scrollY,
|
||||||
0,
|
0,
|
||||||
@ -336,7 +348,7 @@ export function Header() {
|
|||||||
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
|
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
|
||||||
|
|
||||||
setProperty('--avatar-border-transform', borderTransform)
|
setProperty('--avatar-border-transform', borderTransform)
|
||||||
setProperty('--avatar-border-opacity', scale === toScale ? 1 : 0)
|
setProperty('--avatar-border-opacity', (scale === toScale ? 1 : 0).toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStyles() {
|
function updateStyles() {
|
||||||
@ -346,11 +358,13 @@ export function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateStyles()
|
updateStyles()
|
||||||
window.addEventListener('scroll', updateStyles, {passive: true})
|
|
||||||
|
const opts: AddEventListenerOptions & EventListenerOptions = {passive: true}
|
||||||
|
window.addEventListener('scroll', updateStyles, opts)
|
||||||
window.addEventListener('resize', updateStyles)
|
window.addEventListener('resize', updateStyles)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', updateStyles, {passive: true})
|
window.removeEventListener('scroll', updateStyles, opts)
|
||||||
window.removeEventListener('resize', updateStyles)
|
window.removeEventListener('resize', updateStyles)
|
||||||
}
|
}
|
||||||
}, [isHomePage])
|
}, [isHomePage])
|
||||||
@ -374,10 +388,8 @@ export function Header() {
|
|||||||
className="top-0 order-last -mb-3 pt-3"
|
className="top-0 order-last -mb-3 pt-3"
|
||||||
style={{position: 'var(--header-position)'}}
|
style={{position: 'var(--header-position)'}}
|
||||||
>
|
>
|
||||||
<div
|
<div className="top-[var(--avatar-top,theme(spacing.3))] w-full"
|
||||||
className="top-[var(--avatar-top,theme(spacing.3))] w-full"
|
style={headerInnerPosition}>
|
||||||
style={{position: 'var(--header-inner-position)'}}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<AvatarContainer
|
<AvatarContainer
|
||||||
className="absolute left-0 top-3 origin-left transition-opacity"
|
className="absolute left-0 top-3 origin-left transition-opacity"
|
||||||
@ -399,7 +411,7 @@ export function Header() {
|
|||||||
<div
|
<div
|
||||||
ref={headerRef}
|
ref={headerRef}
|
||||||
className="top-0 z-10 h-16 pt-6"
|
className="top-0 z-10 h-16 pt-6"
|
||||||
style={{position: 'var(--header-position)'}}
|
style={headerPosition}
|
||||||
>
|
>
|
||||||
<Container
|
<Container
|
||||||
className="top-[var(--header-top,theme(spacing.6))] w-full"
|
className="top-[var(--header-top,theme(spacing.6))] w-full"
|
@ -1,7 +0,0 @@
|
|||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
export function Prose({ children, className }) {
|
|
||||||
return (
|
|
||||||
<div className={clsx(className, 'prose dark:prose-invert')}>{children}</div>
|
|
||||||
)
|
|
||||||
}
|
|
8
src/components/Prose.tsx
Normal file
8
src/components/Prose.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import {ReactNode} from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function Prose({children, className}: { children: ReactNode, className: string }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx(className, 'prose dark:prose-invert')}>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,7 +1,19 @@
|
|||||||
import {Container} from '@/components/Container'
|
import {ReactNode} from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import {Container} from './Container'
|
||||||
|
|
||||||
export function SimpleLayout({title, intro, children, gradient}) {
|
export function SimpleLayout({
|
||||||
|
title,
|
||||||
|
intro,
|
||||||
|
children,
|
||||||
|
gradient
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
title: string,
|
||||||
|
intro: string,
|
||||||
|
children: ReactNode,
|
||||||
|
gradient: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Container className="mt-16 sm:mt-32">
|
<Container className="mt-16 sm:mt-32">
|
||||||
<header className="max-w-2xl">
|
<header className="max-w-2xl">
|
@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
42
src/components/SocialIcons.tsx
Normal file
42
src/components/SocialIcons.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import {Props} from 'types'
|
||||||
|
|
||||||
|
export function TwitterIcon(props: 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: 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: 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: 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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,11 +1,44 @@
|
|||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
import fetcher from '@/lib/fetcher'
|
import fetcher from '@/lib/fetcher'
|
||||||
import Image from 'next/future/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {useEffect} from 'react'
|
import {useEffect, ReactElement, ElementType} from 'react'
|
||||||
import {animate} from 'motion'
|
import {animate} from 'motion'
|
||||||
|
|
||||||
|
type PlayerStateParams = {
|
||||||
|
path: string
|
||||||
|
options?: {
|
||||||
|
refreshInterval: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = {
|
||||||
|
as?: ElementType
|
||||||
|
isPlaying: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Artist = {
|
||||||
|
as?: ElementType
|
||||||
|
artist: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Title = {
|
||||||
|
as?: ElementType
|
||||||
|
title: string
|
||||||
|
songUrl: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Album = {
|
||||||
|
album: string
|
||||||
|
albumImageUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Song = {
|
||||||
|
as?: ElementType
|
||||||
|
} & Artist & Title & Album & Status
|
||||||
|
|
||||||
function AnimatedBars() {
|
function AnimatedBars() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
animate(
|
animate(
|
||||||
@ -75,7 +108,7 @@ function AnimatedBars() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePlayerState(path, options) {
|
function usePlayerState({path, options}: PlayerStateParams) {
|
||||||
const {data, error, isLoading} = useSWR(`/api/spotify/${path}`, fetcher, options)
|
const {data, error, isLoading} = useSWR(`/api/spotify/${path}`, fetcher, options)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -85,7 +118,7 @@ function usePlayerState(path, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Song({as: Component = 'div', artist, title, songUrl, album, albumImageUrl, isPlaying}) {
|
function Song({as: Component = 'div', artist, title, songUrl, album, albumImageUrl, isPlaying}: Song) {
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className="flex items-center space-x-4">
|
className="flex items-center space-x-4">
|
||||||
@ -110,7 +143,7 @@ function Song({as: Component = 'div', artist, title, songUrl, album, albumImageU
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Song.Album = function SongAlbum({album, albumImageUrl}) {
|
Song.Album = function SongAlbum({album, albumImageUrl}: Album) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
width="64"
|
width="64"
|
||||||
@ -122,7 +155,7 @@ Song.Album = function SongAlbum({album, albumImageUrl}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Song.Title = function SongTitle({as: Component = 'h2', title, songUrl, className}) {
|
Song.Title = function SongTitle({as: Component = 'h2', title, songUrl, className}: Title) {
|
||||||
return (
|
return (
|
||||||
<Component className={clsx(className, 'text-sm font-semibold text-zinc-800')}>
|
<Component className={clsx(className, 'text-sm font-semibold text-zinc-800')}>
|
||||||
<Link href={songUrl}>
|
<Link href={songUrl}>
|
||||||
@ -132,7 +165,7 @@ Song.Title = function SongTitle({as: Component = 'h2', title, songUrl, className
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Song.Artist = function SongArtist({as: Component = 'p', artist}) {
|
Song.Artist = function SongArtist({as: Component = 'p', artist}: Artist) {
|
||||||
return (
|
return (
|
||||||
<Component className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none">
|
<Component className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none">
|
||||||
{artist}
|
{artist}
|
||||||
@ -140,9 +173,9 @@ Song.Artist = function SongArtist({as: Component = 'p', artist}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Song.Skeleton = function SongSkeleton({as: Component = 'div'}) {
|
Song.Skeleton = function SongSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Component className="flex items-center space-x-4 animate-pulse">
|
<div className="flex items-center space-x-4 animate-pulse">
|
||||||
<div
|
<div
|
||||||
className="w-[64px] h-[64px] bg-zinc-100 rounded-2xl dark:bg-zinc-900"
|
className="w-[64px] h-[64px] bg-zinc-100 rounded-2xl dark:bg-zinc-900"
|
||||||
/>
|
/>
|
||||||
@ -150,14 +183,18 @@ Song.Skeleton = function SongSkeleton({as: Component = 'div'}) {
|
|||||||
<p className="w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
|
<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"/>
|
<p className="mt-3 w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
|
||||||
</div>
|
</div>
|
||||||
</Component>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LastPlayed() {
|
function LastPlayed(): ReactElement | null {
|
||||||
const {song, isLoading, isError} = usePlayerState('last-played')
|
const {song, isLoading, isError} = usePlayerState(
|
||||||
|
{
|
||||||
|
path: 'last-played'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (isError) return
|
if (isError) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -170,13 +207,19 @@ function LastPlayed() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpotifyPlayer() {
|
export function SpotifyPlayer(): ReactElement | null {
|
||||||
const {song, isLoading, isError} = usePlayerState('currently-playing', {refreshInterval: 30000})
|
const {song, isLoading, isError} = usePlayerState(
|
||||||
|
{
|
||||||
|
path: 'currently-playing',
|
||||||
|
options: {
|
||||||
|
refreshInterval: 30000
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (isError) return
|
if (isError) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid place-items-start">
|
<div className="grid">
|
||||||
{isLoading
|
{isLoading
|
||||||
? <Song.Skeleton/>
|
? <Song.Skeleton/>
|
||||||
: song?.isPlaying
|
: song?.isPlaying
|
@ -28,7 +28,8 @@ export async function generateRssFeed() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (let article of articles) {
|
for (let article of articles) {
|
||||||
let url = `${siteUrl}/writing/${article.slug}`
|
let url = `${siteUrl}/writing/${article.slug}`;
|
||||||
|
|
||||||
let html = ReactDOMServer.renderToStaticMarkup(
|
let html = ReactDOMServer.renderToStaticMarkup(
|
||||||
<article.component isRssFeed/>
|
<article.component isRssFeed/>
|
||||||
)
|
)
|
||||||
|
67
src/lib/generateSitemap.js
Normal file
67
src/lib/generateSitemap.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import glob from 'fast-glob'
|
||||||
|
import path from 'path'
|
||||||
|
import {getAllArticles} from '@/lib/getAllArticles'
|
||||||
|
import {writeFile} from 'fs/promises'
|
||||||
|
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL
|
||||||
|
|
||||||
|
async function createSitemap(pages) {
|
||||||
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${pages.map((url) => {
|
||||||
|
return `
|
||||||
|
<url>
|
||||||
|
<loc>${url}</loc>
|
||||||
|
<lastmod>${new Date().toISOString()}</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>`
|
||||||
|
}).join("")}
|
||||||
|
</urlset>`
|
||||||
|
|
||||||
|
await writeFile('./public/sitemap.xml', sitemap, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRobots() {
|
||||||
|
const robots = `# *
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Host
|
||||||
|
Host: ${BASE_URL}
|
||||||
|
|
||||||
|
# Sitemaps
|
||||||
|
Sitemap: ${BASE_URL}/sitemap.xml`
|
||||||
|
|
||||||
|
await writeFile('./public/robots.txt', robots, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSitemap() {
|
||||||
|
const excluded = [
|
||||||
|
'_app.tsx',
|
||||||
|
'_document.tsx',
|
||||||
|
'index.tsx'
|
||||||
|
]
|
||||||
|
|
||||||
|
const pages = (await glob(['*.tsx', '*.jsx'], {
|
||||||
|
cwd: path.join(process.cwd(), 'src/pages/'),
|
||||||
|
})).filter((page) => {
|
||||||
|
return !excluded
|
||||||
|
.includes(page)
|
||||||
|
}).map((page) => {
|
||||||
|
return `${BASE_URL}/${page}`
|
||||||
|
.replace(/\.(tsx|jsx)$/, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
pages.unshift(`${BASE_URL}/`)
|
||||||
|
pages.push(`${BASE_URL}/writing`)
|
||||||
|
|
||||||
|
const articles = await getAllArticles()
|
||||||
|
const slugs = articles.map(({slug}) => `${BASE_URL}/writing/${slug}`)
|
||||||
|
const allPages = [...pages, ...slugs]
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
await createSitemap(allPages),
|
||||||
|
await createRobots()
|
||||||
|
])
|
||||||
|
}
|
@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
38
src/pages/_app.tsx
Normal file
38
src/pages/_app.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {useEffect, useRef} from 'react'
|
||||||
|
import type {AppProps} from 'next/app'
|
||||||
|
import {Header} from '@/components/Header'
|
||||||
|
import {Footer} from '@/components/Footer'
|
||||||
|
|
||||||
|
import '../../styles/tailwind.css'
|
||||||
|
import 'focus-visible'
|
||||||
|
|
||||||
|
function usePrevious(value: string): string | null {
|
||||||
|
let ref = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = value
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return ref.current
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App({Component, pageProps, router}: AppProps) {
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import {Head, Html, Main, NextScript} from 'next/document'
|
import {Html, Head, Main, NextScript} from 'next/document'
|
||||||
|
|
||||||
const modeScript = `
|
const modeScript = `
|
||||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
updateMode()
|
updateMode()
|
||||||
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
|
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
|
||||||
@ -67,10 +67,11 @@ export default function Document() {
|
|||||||
href="/static/icons/apple-touch-icon.png"
|
href="/static/icons/apple-touch-icon.png"
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
|
||||||
<body className="flex h-full flex-col dark:bg-black">
|
<body className="flex h-full flex-col dark:bg-black">
|
||||||
<Main/>
|
<Main/>
|
||||||
<NextScript/>
|
<NextScript/>
|
||||||
</body>
|
</body>
|
||||||
</Html>
|
</Html>
|
||||||
)
|
);
|
||||||
}
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import Image from 'next/future/image'
|
import Image from 'next/image'
|
||||||
|
import {ElementType, ReactNode} from 'react'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -9,10 +10,22 @@ import {
|
|||||||
LinkedInIcon,
|
LinkedInIcon,
|
||||||
TwitterIcon
|
TwitterIcon
|
||||||
} from '@/components/SocialIcons'
|
} from '@/components/SocialIcons'
|
||||||
|
import {Props} from 'types'
|
||||||
import photoOfMeLg from '@/images/photo-of-me-lg.jpg'
|
import photoOfMeLg from '@/images/photo-of-me-lg.jpg'
|
||||||
import awsCCPBadge from '@/images/aws-certified-cloud-practitioner-badge.png'
|
import awsCCPBadge from '@/images/aws-certified-cloud-practitioner-badge.png'
|
||||||
|
|
||||||
function SocialLink({className, href, children, icon: Icon}) {
|
function SocialLink({
|
||||||
|
className,
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
icon: Icon
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
className: string,
|
||||||
|
href: string,
|
||||||
|
children: ReactNode,
|
||||||
|
icon: ElementType
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<li className={clsx(className, 'flex')}>
|
<li className={clsx(className, 'flex')}>
|
||||||
<Link
|
<Link
|
||||||
@ -26,7 +39,7 @@ function SocialLink({className, href, children, icon: Icon}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailIcon(props) {
|
function MailIcon(props: Props) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||||
<path
|
<path
|
@ -1,19 +1,41 @@
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import {GetStaticProps} from 'next'
|
||||||
|
import {ElementType} from 'react'
|
||||||
|
|
||||||
import {Button} from '@/components/Button'
|
|
||||||
import {Card} from '@/components/Card'
|
import {Card} from '@/components/Card'
|
||||||
|
import {Button} from '@/components/Button'
|
||||||
import {Container} from '@/components/Container'
|
import {Container} from '@/components/Container'
|
||||||
import {
|
import {
|
||||||
GitHubIcon,
|
GitHubIcon,
|
||||||
LinkedInIcon,
|
LinkedInIcon,
|
||||||
TwitterIcon
|
TwitterIcon
|
||||||
} from '@/components/SocialIcons'
|
} from '@/components/SocialIcons'
|
||||||
import {generateRssFeed} from '@/lib/generateRssFeed'
|
|
||||||
import {getAllArticles} from '@/lib/getAllArticles'
|
|
||||||
import {formatDate} from '@/lib/formatDate'
|
import {formatDate} from '@/lib/formatDate'
|
||||||
|
import {generateRssFeed} from '@/lib/generateRssFeed'
|
||||||
|
import {generateSitemap} from '@/lib/generateSitemap'
|
||||||
|
import {getAllArticles} from '@/lib/getAllArticles'
|
||||||
|
import {Article} from 'types'
|
||||||
|
|
||||||
function BriefcaseIcon(props) {
|
type Work = {
|
||||||
|
company: string
|
||||||
|
title: string
|
||||||
|
start: {
|
||||||
|
label: string
|
||||||
|
dateTime: string
|
||||||
|
}
|
||||||
|
end: {
|
||||||
|
label: string
|
||||||
|
dateTime: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocialLink = {
|
||||||
|
href: string
|
||||||
|
icon: ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
function BriefcaseIcon(props: { className: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -36,7 +58,7 @@ function BriefcaseIcon(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArrowDownIcon(props) {
|
function ArrowDownIcon(props: { className: string }) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||||
<path
|
<path
|
||||||
@ -49,7 +71,7 @@ function ArrowDownIcon(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Article({article}) {
|
function Article(article: Article) {
|
||||||
return (
|
return (
|
||||||
<Card as="article">
|
<Card as="article">
|
||||||
<Card.Title href={`/writing/${article.slug}`}>
|
<Card.Title href={`/writing/${article.slug}`}>
|
||||||
@ -64,9 +86,9 @@ function Article({article}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SocialLink({icon: Icon, ...props}) {
|
function SocialLink({icon: Icon, href}: SocialLink) {
|
||||||
return (
|
return (
|
||||||
<Link className="group -m-1 p-1" {...props}>
|
<Link className="group -m-1 p-1" href={href}>
|
||||||
<Icon
|
<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"/>
|
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>
|
</Link>
|
||||||
@ -74,21 +96,30 @@ function SocialLink({icon: Icon, ...props}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Resume() {
|
function Resume() {
|
||||||
let work = [
|
const work: Work[] = [
|
||||||
{
|
{
|
||||||
company: 'Aer Lingus',
|
company: 'Aer Lingus',
|
||||||
title: 'Software engineer',
|
title: 'Software engineer',
|
||||||
start: '2022',
|
start: {
|
||||||
|
label: '2022',
|
||||||
|
dateTime: '2022'
|
||||||
|
},
|
||||||
end: {
|
end: {
|
||||||
label: 'present',
|
label: 'present',
|
||||||
dateTime: new Date().getFullYear(),
|
dateTime: new Date().getFullYear().toString(),
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
company: 'Apple',
|
company: 'Apple',
|
||||||
title: 'At home advisor',
|
title: 'At home advisor',
|
||||||
start: '2014',
|
start: {
|
||||||
end: '2018',
|
label: '2014',
|
||||||
|
dateTime: '2014'
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
label: '2018',
|
||||||
|
dateTime: '2018'
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -139,7 +170,7 @@ function Resume() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({articles}) {
|
export default function Home({articles}: { articles: Article[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -224,8 +255,14 @@ export default function Home({articles}) {
|
|||||||
<Container className="mt-24 md:mt-28">
|
<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="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">
|
<div className="flex flex-col gap-16">
|
||||||
{articles.map((article) => (
|
{articles.map(({slug, title, description, date}) => (
|
||||||
<Article key={article.slug} article={article}/>
|
<Article
|
||||||
|
key={slug}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
slug={slug}
|
||||||
|
date={date}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-10 lg:pl-16 xl:pl-24">
|
<div className="space-y-10 lg:pl-16 xl:pl-24">
|
||||||
@ -237,9 +274,10 @@ export default function Home({articles}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export const getStaticProps: GetStaticProps = async () => {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
await generateRssFeed()
|
await generateRssFeed()
|
||||||
|
await generateSitemap()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -247,6 +285,6 @@ export async function getStaticProps() {
|
|||||||
articles: (await getAllArticles())
|
articles: (await getAllArticles())
|
||||||
.slice(0, 1)
|
.slice(0, 1)
|
||||||
.map(({component, ...meta}) => meta),
|
.map(({component, ...meta}) => meta),
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,8 +2,18 @@ import Head from 'next/head'
|
|||||||
|
|
||||||
import {Card} from '@/components/Card'
|
import {Card} from '@/components/Card'
|
||||||
import {SimpleLayout} from '@/components/SimpleLayout'
|
import {SimpleLayout} from '@/components/SimpleLayout'
|
||||||
|
import {Props} from 'types'
|
||||||
|
|
||||||
const projects = [
|
type Project = {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
link: {
|
||||||
|
href: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects: Project[] = [
|
||||||
{
|
{
|
||||||
name: 'Portfolio',
|
name: 'Portfolio',
|
||||||
description:
|
description:
|
||||||
@ -36,7 +46,7 @@ const projects = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
function LinkIcon(props) {
|
function LinkIcon(props: Props) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||||
<path
|
<path
|
@ -3,8 +3,6 @@ import Head from 'next/head'
|
|||||||
import {Card} from '@/components/Card'
|
import {Card} from '@/components/Card'
|
||||||
import {Section} from '@/components/Section'
|
import {Section} from '@/components/Section'
|
||||||
import {SimpleLayout} from '@/components/SimpleLayout'
|
import {SimpleLayout} from '@/components/SimpleLayout'
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
|
|
||||||
function ToolsSection({children, ...props}) {
|
function ToolsSection({children, ...props}) {
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import {ArticleLayout} from '@/components/ArticleLayout'
|
import {ArticleLayout} from '@/components/ArticleLayout'
|
||||||
import Image from 'next/future/image'
|
|
||||||
import forestForkingPath from './jens-lelie-u0vgcIOQG08-unsplash.jpg'
|
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
author: 'Ryan Freeman',
|
author: 'Ryan Freeman',
|
||||||
@ -8,16 +6,13 @@ export const meta = {
|
|||||||
title: 'A personal journey in software engineering',
|
title: 'A personal journey in software engineering',
|
||||||
description:
|
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.',
|
'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} />
|
export default (props) => <ArticleLayout
|
||||||
|
author={meta.author}
|
||||||
<Image
|
date={meta.date}
|
||||||
src={forestForkingPath}
|
title={meta.title}
|
||||||
alt="Photo by Jens Lelie on Unsplash"
|
description={meta.description} {...props} />
|
||||||
placeholder="blur"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Hello there!
|
Hello there!
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 830 KiB |
@ -7,7 +7,11 @@ export const meta = {
|
|||||||
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.'
|
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} />
|
export default (props) => <ArticleLayout
|
||||||
|
author={meta.author}
|
||||||
|
date={meta.date}
|
||||||
|
title={meta.title}
|
||||||
|
description={meta.description} {...props} />
|
||||||
|
|
||||||
# Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`.
|
# Next.js includes support for TypeScript by default. To add TypeScript to an existing Next.js project create a _tsconfig.json_ file in the project root with `touch tsconfig.json`.
|
||||||
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.
|
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.
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import {GetStaticProps} from 'next'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
|
|
||||||
import {Card} from '@/components/Card'
|
import {Card} from '@/components/Card'
|
||||||
@ -5,7 +6,9 @@ import {SimpleLayout} from '@/components/SimpleLayout'
|
|||||||
import {getAllArticles} from '@/lib/getAllArticles'
|
import {getAllArticles} from '@/lib/getAllArticles'
|
||||||
import {formatDate} from '@/lib/formatDate'
|
import {formatDate} from '@/lib/formatDate'
|
||||||
|
|
||||||
function Article({article}) {
|
import {Article} from 'types'
|
||||||
|
|
||||||
|
function Article({article}: { article: Article }) {
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
<Card small={true}>
|
<Card small={true}>
|
||||||
@ -25,7 +28,7 @@ function Article({article}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArticlesIndex({articles}) {
|
export default function ArticlesIndex({articles}: { articles: Article[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -60,10 +63,10 @@ export default function ArticlesIndex({articles}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export const getStaticProps: GetStaticProps = async () => {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
articles: (await getAllArticles()).map(({component, ...meta}) => meta),
|
articles: (await getAllArticles()).map(({component, ...meta}) => meta),
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./src/**/*.{js,jsx}'],
|
content: ['./src/**/*.{js,jsx,tsx}'],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/typography'),
|
require('@tailwindcss/typography'),
|
||||||
|
36
tsconfig.json
Normal file
36
tsconfig.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
13
types/index.ts
Normal file
13
types/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import {ReactNode} from 'react'
|
||||||
|
|
||||||
|
export type Article = {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
className?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user