mirror of
https://github.com/r-freeman/portfolio.git
synced 2024-11-24 12:35: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.
|
||||
|
||||
.idea/
|
||||
|
||||
public/rss
|
||||
public/sitemap.xml
|
||||
public/robots.txt
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
@ -31,6 +37,6 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# generated files
|
||||
/public/rss/
|
||||
.idea/
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
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
|
||||
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.
|
||||
## Features
|
||||
|
||||
Feel free to explore the different sections, and contact me if you have any questions or would like to work
|
||||
together.
|
||||
- 📝 TypeScript.
|
||||
- 💨 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": {
|
||||
"baseUrl": ".",
|
||||
"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} */
|
||||
const nextConfig = {
|
||||
pageExtensions: ['jsx', 'js', 'mdx'],
|
||||
pageExtensions: ['jsx', 'js', 'tsx', 'mdx'],
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
experimental: {
|
||||
newNextLinkBehavior: true,
|
||||
scrollRestoration: true,
|
||||
scrollRestoration: true
|
||||
},
|
||||
images: {
|
||||
domains: ['i.scdn.co']
|
||||
@ -29,7 +29,7 @@ const withMDX = nextMDX({
|
||||
options: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypePrism],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && next-sitemap",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"browserslist": "defaults, not ie <= 11",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.0",
|
||||
"@headlessui/react": "^1.7.7",
|
||||
"@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",
|
||||
"@next/mdx": "^13.1.1",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@tailwindcss/typography": "^0.5.8",
|
||||
"@types/mdx": "^2.0.3",
|
||||
"@types/node": "18.11.18",
|
||||
"@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",
|
||||
"focus-visible": "^5.2.0",
|
||||
"motion": "^10.15.3",
|
||||
"next": "^12.3.0",
|
||||
"next-sitemap": "^3.1.44",
|
||||
"motion": "^10.15.5",
|
||||
"next": "13.1.2",
|
||||
"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-dom": "18.2.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.31.3",
|
||||
"swr": "^2.0.0",
|
||||
"tailwindcss": "^3.1.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"
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "4.9.4"
|
||||
}
|
||||
}
|
||||
|
@ -5,5 +5,5 @@ module.exports = {
|
||||
replaceWith: '[data-focus-visible-added]',
|
||||
},
|
||||
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 {useRouter} from 'next/router'
|
||||
|
||||
import {Container} from '@/components/Container'
|
||||
import {usePathname} from 'next/navigation'
|
||||
import {Container} from './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>
|
||||
)
|
||||
}
|
||||
import {Prose} from './Prose'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
export function ArticleLayout({
|
||||
children,
|
||||
meta,
|
||||
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) {
|
||||
return children
|
||||
@ -33,11 +30,11 @@ export function ArticleLayout({
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{`${meta.title} - Ryan Freeman`}</title>
|
||||
<meta name="description" content={meta.description}/>
|
||||
<title>{`${title} - Ryan Freeman`}</title>
|
||||
<meta name="description" content={description}/>
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${pathname}`}
|
||||
/>
|
||||
<meta
|
||||
property="og:type"
|
||||
@ -45,17 +42,17 @@ export function ArticleLayout({
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={meta.title}
|
||||
content={title}
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={meta.description}
|
||||
content={description}
|
||||
/>
|
||||
{meta.ogImage &&
|
||||
{ogImage &&
|
||||
<>
|
||||
<meta
|
||||
property="og:image"
|
||||
content={meta.ogImage}
|
||||
content={ogImage}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:card"
|
||||
@ -63,7 +60,7 @@ export function ArticleLayout({
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={meta.ogImage}
|
||||
content={ogImage}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
@ -73,41 +70,30 @@ export function ArticleLayout({
|
||||
/>
|
||||
<meta
|
||||
property="twitter:url"
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${router.pathname}`}
|
||||
content={`${process.env.NEXT_PUBLIC_SITE_URL}${pathname}`}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content={meta.title}
|
||||
content={title}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content={meta.description}
|
||||
content={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}
|
||||
{title}
|
||||
</h1>
|
||||
<time
|
||||
dateTime={meta.date}
|
||||
dateTime={date}
|
||||
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>
|
||||
</header>
|
||||
<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 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 (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<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 (
|
||||
<Component
|
||||
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 (
|
||||
<>
|
||||
<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}>
|
||||
<Link href={href}>
|
||||
<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>
|
||||
@ -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 (
|
||||
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||
{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 (
|
||||
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{children}
|
||||
@ -53,7 +94,7 @@ Card.Description = function CardDescription({children}) {
|
||||
)
|
||||
}
|
||||
|
||||
Card.Cta = function CardCta({children}) {
|
||||
Card.Cta = function CardCta({children}: CardCta) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@ -71,7 +112,7 @@ Card.Eyebrow = function CardEyebrow({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
}: CardEyebrow) {
|
||||
return (
|
||||
<Component
|
||||
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 {ReactNode} from 'react'
|
||||
|
||||
import {Container} from '@/components/Container'
|
||||
import {SpotifyPlayer} from '@/components/SpotifyPlayer'
|
||||
import {OuterContainer, InnerContainer} from './Container'
|
||||
import {SpotifyPlayer} from './SpotifyPlayer'
|
||||
|
||||
function NavLink({href, children}) {
|
||||
function NavLink({href, children}: { href: string, children: ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
@ -17,9 +18,9 @@ function NavLink({href, children}) {
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-32">
|
||||
<Container.Outer>
|
||||
<OuterContainer>
|
||||
<div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
|
||||
<Container.Inner>
|
||||
<InnerContainer>
|
||||
<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">
|
||||
@ -29,9 +30,9 @@ export function Footer() {
|
||||
<NavLink href="/uses">Uses</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container.Inner>
|
||||
</InnerContainer>
|
||||
</div>
|
||||
</Container.Outer>
|
||||
</OuterContainer>
|
||||
</footer>
|
||||
)
|
||||
}
|
@ -1,14 +1,16 @@
|
||||
import Image from 'next/future/image'
|
||||
import Image from 'next/image'
|
||||
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 clsx from 'clsx'
|
||||
|
||||
import {Container} from '@/components/Container'
|
||||
import {Container} from './Container'
|
||||
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 (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
@ -23,7 +25,7 @@ function CloseIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon(props) {
|
||||
function ChevronDownIcon(props: Props) {
|
||||
return (
|
||||
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
|
||||
<path
|
||||
@ -37,7 +39,7 @@ function ChevronDownIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function SunIcon(props) {
|
||||
function SunIcon(props: Props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@ -57,7 +59,7 @@ function SunIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon(props) {
|
||||
function MoonIcon(props: Props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
@ -70,7 +72,7 @@ function MoonIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNavItem({href, children}) {
|
||||
function MobileNavItem({href, children}: { href: string } & Props) {
|
||||
return (
|
||||
<li>
|
||||
<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 (
|
||||
<Popover {...props}>
|
||||
<Popover.Button
|
||||
@ -137,8 +139,8 @@ function MobileNavigation(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({href, children}) {
|
||||
let isActive = useRouter().pathname === href
|
||||
function NavItem({href, children}: { href: string } & Props) {
|
||||
let isActive = usePathname() === href
|
||||
|
||||
return (
|
||||
<li>
|
||||
@ -161,7 +163,7 @@ function NavItem({href, children}) {
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopNavigation(props) {
|
||||
function DesktopNavigation(props: 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">
|
||||
@ -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 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 (
|
||||
<div
|
||||
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 (
|
||||
<Link
|
||||
href="/"
|
||||
@ -253,26 +255,36 @@ function Avatar({large = false, className, ...props}) {
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
let isHomePage = useRouter().pathname === '/'
|
||||
const isHomePage = usePathname() === '/'
|
||||
|
||||
let headerRef = useRef()
|
||||
let avatarRef = useRef()
|
||||
let isInitial = useRef(true)
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
const avatarRef = useRef<HTMLImageElement>(null)
|
||||
const isInitial = useRef(true)
|
||||
|
||||
const headerPosition: Object = {
|
||||
position: 'var(--header-position)'
|
||||
}
|
||||
const headerInnerPosition: Object = {
|
||||
position: 'var(--header-inner-position)'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let downDelay = avatarRef.current?.offsetTop ?? 0
|
||||
let upDelay = 64
|
||||
|
||||
function setProperty(property, value) {
|
||||
document.documentElement.style.setProperty(property, value)
|
||||
function setProperty(property: string, value: string) {
|
||||
document.documentElement.style.setProperty(property, value.toString())
|
||||
}
|
||||
|
||||
function removeProperty(property) {
|
||||
function removeProperty(property: string) {
|
||||
document.documentElement.style.removeProperty(property)
|
||||
}
|
||||
|
||||
function updateHeaderStyles() {
|
||||
let {top, height} = headerRef.current.getBoundingClientRect()
|
||||
const headerBoundingRect = headerRef.current?.getBoundingClientRect()
|
||||
let top = headerBoundingRect?.top!
|
||||
let height = headerBoundingRect?.height!
|
||||
|
||||
let scrollY = clamp(
|
||||
window.scrollY,
|
||||
0,
|
||||
@ -336,7 +348,7 @@ export function Header() {
|
||||
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
|
||||
|
||||
setProperty('--avatar-border-transform', borderTransform)
|
||||
setProperty('--avatar-border-opacity', scale === toScale ? 1 : 0)
|
||||
setProperty('--avatar-border-opacity', (scale === toScale ? 1 : 0).toString())
|
||||
}
|
||||
|
||||
function updateStyles() {
|
||||
@ -346,11 +358,13 @@ export function Header() {
|
||||
}
|
||||
|
||||
updateStyles()
|
||||
window.addEventListener('scroll', updateStyles, {passive: true})
|
||||
|
||||
const opts: AddEventListenerOptions & EventListenerOptions = {passive: true}
|
||||
window.addEventListener('scroll', updateStyles, opts)
|
||||
window.addEventListener('resize', updateStyles)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateStyles, {passive: true})
|
||||
window.removeEventListener('scroll', updateStyles, opts)
|
||||
window.removeEventListener('resize', updateStyles)
|
||||
}
|
||||
}, [isHomePage])
|
||||
@ -374,10 +388,8 @@ export function Header() {
|
||||
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="top-[var(--avatar-top,theme(spacing.3))] w-full"
|
||||
style={headerInnerPosition}>
|
||||
<div className="relative">
|
||||
<AvatarContainer
|
||||
className="absolute left-0 top-3 origin-left transition-opacity"
|
||||
@ -399,7 +411,7 @@ export function Header() {
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="top-0 z-10 h-16 pt-6"
|
||||
style={{position: 'var(--header-position)'}}
|
||||
style={headerPosition}
|
||||
>
|
||||
<Container
|
||||
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 {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 (
|
||||
<Container className="mt-16 sm:mt-32">
|
||||
<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 fetcher from '@/lib/fetcher'
|
||||
import Image from 'next/future/image'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
import {useEffect} from 'react'
|
||||
import {useEffect, ReactElement, ElementType} from 'react'
|
||||
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() {
|
||||
useEffect(() => {
|
||||
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)
|
||||
|
||||
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 (
|
||||
<Component
|
||||
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 (
|
||||
<Image
|
||||
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 (
|
||||
<Component className={clsx(className, 'text-sm font-semibold text-zinc-800')}>
|
||||
<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 (
|
||||
<Component className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none">
|
||||
{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 (
|
||||
<Component className="flex items-center space-x-4 animate-pulse">
|
||||
<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"
|
||||
/>
|
||||
@ -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="mt-3 w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
|
||||
</div>
|
||||
</Component>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LastPlayed() {
|
||||
const {song, isLoading, isError} = usePlayerState('last-played')
|
||||
function LastPlayed(): ReactElement | null {
|
||||
const {song, isLoading, isError} = usePlayerState(
|
||||
{
|
||||
path: 'last-played'
|
||||
}
|
||||
)
|
||||
|
||||
if (isError) return
|
||||
if (isError) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -170,13 +207,19 @@ function LastPlayed() {
|
||||
)
|
||||
}
|
||||
|
||||
export function SpotifyPlayer() {
|
||||
const {song, isLoading, isError} = usePlayerState('currently-playing', {refreshInterval: 30000})
|
||||
export function SpotifyPlayer(): ReactElement | null {
|
||||
const {song, isLoading, isError} = usePlayerState(
|
||||
{
|
||||
path: 'currently-playing',
|
||||
options: {
|
||||
refreshInterval: 30000
|
||||
}
|
||||
})
|
||||
|
||||
if (isError) return
|
||||
if (isError) return null
|
||||
|
||||
return (
|
||||
<div className="grid place-items-start">
|
||||
<div className="grid">
|
||||
{isLoading
|
||||
? <Song.Skeleton/>
|
||||
: song?.isPlaying
|
@ -28,7 +28,8 @@ export async function generateRssFeed() {
|
||||
})
|
||||
|
||||
for (let article of articles) {
|
||||
let url = `${siteUrl}/writing/${article.slug}`
|
||||
let url = `${siteUrl}/writing/${article.slug}`;
|
||||
|
||||
let html = ReactDOMServer.renderToStaticMarkup(
|
||||
<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 = `
|
||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
updateMode()
|
||||
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
|
||||
@ -67,10 +67,11 @@ export default function Document() {
|
||||
href="/static/icons/apple-touch-icon.png"
|
||||
/>
|
||||
</Head>
|
||||
<script dangerouslySetInnerHTML={{__html: modeScript}}/>
|
||||
<body className="flex h-full flex-col dark:bg-black">
|
||||
<Main/>
|
||||
<NextScript/>
|
||||
</body>
|
||||
</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 Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -9,10 +10,22 @@ import {
|
||||
LinkedInIcon,
|
||||
TwitterIcon
|
||||
} from '@/components/SocialIcons'
|
||||
import {Props} from 'types'
|
||||
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}) {
|
||||
function SocialLink({
|
||||
className,
|
||||
href,
|
||||
children,
|
||||
icon: Icon
|
||||
}:
|
||||
{
|
||||
className: string,
|
||||
href: string,
|
||||
children: ReactNode,
|
||||
icon: ElementType
|
||||
}) {
|
||||
return (
|
||||
<li className={clsx(className, 'flex')}>
|
||||
<Link
|
||||
@ -26,7 +39,7 @@ function SocialLink({className, href, children, icon: Icon}) {
|
||||
)
|
||||
}
|
||||
|
||||
function MailIcon(props) {
|
||||
function MailIcon(props: Props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
@ -1,19 +1,41 @@
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import {GetStaticProps} from 'next'
|
||||
import {ElementType} from 'react'
|
||||
|
||||
import {Button} from '@/components/Button'
|
||||
import {Card} from '@/components/Card'
|
||||
import {Button} from '@/components/Button'
|
||||
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'
|
||||
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 (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@ -36,7 +58,7 @@ function BriefcaseIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowDownIcon(props) {
|
||||
function ArrowDownIcon(props: { className: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
@ -49,7 +71,7 @@ function ArrowDownIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function Article({article}) {
|
||||
function Article(article: Article) {
|
||||
return (
|
||||
<Card as="article">
|
||||
<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 (
|
||||
<Link className="group -m-1 p-1" {...props}>
|
||||
<Link className="group -m-1 p-1" href={href}>
|
||||
<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>
|
||||
@ -74,21 +96,30 @@ function SocialLink({icon: Icon, ...props}) {
|
||||
}
|
||||
|
||||
function Resume() {
|
||||
let work = [
|
||||
const work: Work[] = [
|
||||
{
|
||||
company: 'Aer Lingus',
|
||||
title: 'Software engineer',
|
||||
start: '2022',
|
||||
start: {
|
||||
label: '2022',
|
||||
dateTime: '2022'
|
||||
},
|
||||
end: {
|
||||
label: 'present',
|
||||
dateTime: new Date().getFullYear(),
|
||||
},
|
||||
dateTime: new Date().getFullYear().toString(),
|
||||
}
|
||||
},
|
||||
{
|
||||
company: 'Apple',
|
||||
title: 'At home advisor',
|
||||
start: '2014',
|
||||
end: '2018',
|
||||
start: {
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
@ -224,8 +255,14 @@ export default function Home({articles}) {
|
||||
<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}/>
|
||||
{articles.map(({slug, title, description, date}) => (
|
||||
<Article
|
||||
key={slug}
|
||||
title={title}
|
||||
description={description}
|
||||
slug={slug}
|
||||
date={date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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') {
|
||||
await generateRssFeed()
|
||||
await generateSitemap()
|
||||
}
|
||||
|
||||
return {
|
||||
@ -247,6 +285,6 @@ export async function getStaticProps() {
|
||||
articles: (await getAllArticles())
|
||||
.slice(0, 1)
|
||||
.map(({component, ...meta}) => meta),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
@ -2,8 +2,18 @@ import Head from 'next/head'
|
||||
|
||||
import {Card} from '@/components/Card'
|
||||
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',
|
||||
description:
|
||||
@ -36,7 +46,7 @@ const projects = [
|
||||
}
|
||||
]
|
||||
|
||||
function LinkIcon(props) {
|
||||
function LinkIcon(props: Props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
@ -3,8 +3,6 @@ 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 (
|
||||
|
@ -1,6 +1,4 @@
|
||||
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',
|
||||
@ -8,16 +6,13 @@ export const meta = {
|
||||
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"
|
||||
/>
|
||||
export default (props) => <ArticleLayout
|
||||
author={meta.author}
|
||||
date={meta.date}
|
||||
title={meta.title}
|
||||
description={meta.description} {...props} />
|
||||
|
||||
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.'
|
||||
}
|
||||
|
||||
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, 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 {Card} from '@/components/Card'
|
||||
@ -5,7 +6,9 @@ import {SimpleLayout} from '@/components/SimpleLayout'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import {formatDate} from '@/lib/formatDate'
|
||||
|
||||
function Article({article}) {
|
||||
import {Article} from 'types'
|
||||
|
||||
function Article({article}: { article: Article }) {
|
||||
return (
|
||||
<article>
|
||||
<Card small={true}>
|
||||
@ -25,7 +28,7 @@ function Article({article}) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArticlesIndex({articles}) {
|
||||
export default function ArticlesIndex({articles}: { articles: Article[] }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@ -60,10 +63,10 @@ export default function ArticlesIndex({articles}) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticProps() {
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
return {
|
||||
props: {
|
||||
articles: (await getAllArticles()).map(({component, ...meta}) => meta),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{js,jsx}'],
|
||||
content: ['./src/**/*.{js,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
plugins: [
|
||||
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