First iteration of blog
This commit is contained in:
56
components/Card.tsx
Normal file
56
components/Card.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import Image from './Image'
|
||||
import Link from './Link'
|
||||
|
||||
const Card = ({ title, description, imgSrc, href }) => (
|
||||
<div className="md max-w-[544px] p-4 md:w-1/2">
|
||||
<div
|
||||
className={`${
|
||||
imgSrc && 'h-full'
|
||||
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}
|
||||
>
|
||||
{imgSrc &&
|
||||
(href ? (
|
||||
<Link href={href} aria-label={`Link to ${title}`}>
|
||||
<Image
|
||||
alt={title}
|
||||
src={imgSrc}
|
||||
className="object-cover object-center md:h-36 lg:h-48"
|
||||
width={544}
|
||||
height={306}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Image
|
||||
alt={title}
|
||||
src={imgSrc}
|
||||
className="object-cover object-center md:h-36 lg:h-48"
|
||||
width={544}
|
||||
height={306}
|
||||
/>
|
||||
))}
|
||||
<div className="p-6">
|
||||
<h2 className="mb-3 text-2xl font-bold leading-8 tracking-tight">
|
||||
{href ? (
|
||||
<Link href={href} aria-label={`Link to ${title}`}>
|
||||
{title}
|
||||
</Link>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</h2>
|
||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">{description}</p>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-base font-medium leading-6 text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Link to ${title}`}
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Card
|
22
components/Comments.tsx
Normal file
22
components/Comments.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { Comments as CommentsComponent } from 'pliny/comments'
|
||||
import { useState } from 'react'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
export default function Comments({ slug }: { slug: string }) {
|
||||
const [loadComments, setLoadComments] = useState(false)
|
||||
|
||||
if (!siteMetadata.comments?.provider) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{loadComments ? (
|
||||
<CommentsComponent commentsConfig={siteMetadata.comments} slug={slug} />
|
||||
) : (
|
||||
<button onClick={() => setLoadComments(true)}>Load Comments</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
35
components/Footer.tsx
Normal file
35
components/Footer.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import Link from './Link'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import SocialIcon from '@/components/social-icons'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer>
|
||||
<div className="mt-16 flex flex-col items-center">
|
||||
<div className="mb-3 flex space-x-4">
|
||||
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size={6} />
|
||||
<SocialIcon kind="github" href={siteMetadata.github} size={6} />
|
||||
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
|
||||
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
|
||||
<SocialIcon kind="linkedin" href={siteMetadata.linkedin} size={6} />
|
||||
<SocialIcon kind="twitter" href={siteMetadata.twitter} size={6} />
|
||||
<SocialIcon kind="x" href={siteMetadata.x} size={6} />
|
||||
<SocialIcon kind="instagram" href={siteMetadata.instagram} size={6} />
|
||||
<SocialIcon kind="threads" href={siteMetadata.threads} size={6} />
|
||||
</div>
|
||||
<div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div>{siteMetadata.author}</div>
|
||||
<div>{` • `}</div>
|
||||
<div>{`© ${new Date().getFullYear()}`}</div>
|
||||
<div>{` • `}</div>
|
||||
<Link href="/">{siteMetadata.title}</Link>
|
||||
</div>
|
||||
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
||||
Tailwind Nextjs Theme
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
53
components/Header.tsx
Normal file
53
components/Header.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
import Logo from '@/data/logo.svg'
|
||||
import Link from './Link'
|
||||
import MobileNav from './MobileNav'
|
||||
import ThemeSwitch from './ThemeSwitch'
|
||||
import SearchButton from './SearchButton'
|
||||
|
||||
const Header = () => {
|
||||
let headerClass = 'flex items-center w-full bg-white dark:bg-gray-950 justify-between py-10'
|
||||
if (siteMetadata.stickyNav) {
|
||||
headerClass += ' sticky top-0 z-50'
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={headerClass}>
|
||||
<Link href="/" aria-label={siteMetadata.headerTitle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-3">
|
||||
<Logo />
|
||||
</div>
|
||||
{typeof siteMetadata.headerTitle === 'string' ? (
|
||||
<div className="hidden h-6 text-2xl font-semibold sm:block">
|
||||
{siteMetadata.headerTitle}
|
||||
</div>
|
||||
) : (
|
||||
siteMetadata.headerTitle
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4 leading-5 sm:space-x-6">
|
||||
<div className="no-scrollbar hidden max-w-40 items-center space-x-4 overflow-x-auto sm:flex sm:space-x-6 md:max-w-72 lg:max-w-96">
|
||||
{headerNavLinks
|
||||
.filter((link) => link.href !== '/')
|
||||
.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="block font-medium text-gray-900 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<SearchButton />
|
||||
<ThemeSwitch />
|
||||
<MobileNav />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
9
components/Image.tsx
Normal file
9
components/Image.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import NextImage, { ImageProps } from 'next/image'
|
||||
|
||||
const basePath = process.env.BASE_PATH
|
||||
|
||||
const Image = ({ src, ...rest }: ImageProps) => (
|
||||
<NextImage src={`${basePath || ''}${src}`} {...rest} />
|
||||
)
|
||||
|
||||
export default Image
|
27
components/LayoutWrapper.tsx
Normal file
27
components/LayoutWrapper.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Inter } from 'next/font/google'
|
||||
import SectionContainer from './SectionContainer'
|
||||
import Footer from './Footer'
|
||||
import { ReactNode } from 'react'
|
||||
import Header from './Header'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
const LayoutWrapper = ({ children }: Props) => {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div className={`${inter.className} flex h-screen flex-col justify-between font-sans`}>
|
||||
<Header />
|
||||
<main className="mb-auto">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutWrapper
|
23
components/Link.tsx
Normal file
23
components/Link.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import Link from 'next/link'
|
||||
import type { LinkProps } from 'next/link'
|
||||
import { AnchorHTMLAttributes } from 'react'
|
||||
|
||||
const CustomLink = ({ href, ...rest }: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const isInternalLink = href && href.startsWith('/')
|
||||
const isAnchorLink = href && href.startsWith('#')
|
||||
|
||||
if (isInternalLink) {
|
||||
return <Link className="break-words" href={href} {...rest} />
|
||||
}
|
||||
|
||||
if (isAnchorLink) {
|
||||
return <a className="break-words" href={href} {...rest} />
|
||||
}
|
||||
|
||||
return (
|
||||
<a className="break-words" target="_blank" rel="noopener noreferrer" href={href} {...rest} />
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomLink
|
16
components/MDXComponents.tsx
Normal file
16
components/MDXComponents.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import TOCInline from 'pliny/ui/TOCInline'
|
||||
import Pre from 'pliny/ui/Pre'
|
||||
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm'
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
import Image from './Image'
|
||||
import CustomLink from './Link'
|
||||
import TableWrapper from './TableWrapper'
|
||||
|
||||
export const components: MDXComponents = {
|
||||
Image,
|
||||
TOCInline,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
table: TableWrapper,
|
||||
BlogNewsletterForm,
|
||||
}
|
108
components/MobileNav.tsx
Normal file
108
components/MobileNav.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
|
||||
import { Fragment, useState, useEffect, useRef } from 'react'
|
||||
import Link from './Link'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
|
||||
const MobileNav = () => {
|
||||
const [navShow, setNavShow] = useState(false)
|
||||
const navRef = useRef(null)
|
||||
|
||||
const onToggleNav = () => {
|
||||
setNavShow((status) => {
|
||||
if (status) {
|
||||
enableBodyScroll(navRef.current)
|
||||
} else {
|
||||
// Prevent scrolling
|
||||
disableBodyScroll(navRef.current)
|
||||
}
|
||||
return !status
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return clearAllBodyScrollLocks
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Toggle Menu" onClick={onToggleNav} className="sm:hidden">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-8 w-8 text-gray-900 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Transition appear show={navShow} as={Fragment} unmount={false}>
|
||||
<Dialog as="div" onClose={onToggleNav} unmount={false}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
unmount={false}
|
||||
>
|
||||
<div className="fixed inset-0 z-60 bg-black/25" />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enterFrom="translate-x-full opacity-0"
|
||||
enterTo="translate-x-0 opacity-95"
|
||||
leave="transition ease-in duration-200 transform"
|
||||
leaveFrom="translate-x-0 opacity-95"
|
||||
leaveTo="translate-x-full opacity-0"
|
||||
unmount={false}
|
||||
>
|
||||
<Dialog.Panel className="fixed left-0 top-0 z-70 h-full w-full bg-white opacity-95 duration-300 dark:bg-gray-950 dark:opacity-[0.98]">
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="mt-8 flex h-full basis-0 flex-col items-start overflow-y-auto pl-12 pt-2 text-left"
|
||||
>
|
||||
{headerNavLinks.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="mb-4 py-2 pr-4 text-2xl font-bold tracking-widest text-gray-900 outline outline-0 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
className="fixed right-4 top-7 z-80 h-16 w-16 p-4 text-gray-900 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNav
|
13
components/PageTitle.tsx
Normal file
13
components/PageTitle.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function PageTitle({ children }: Props) {
|
||||
return (
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14">
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
}
|
61
components/ScrollTopAndComment.tsx
Normal file
61
components/ScrollTopAndComment.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const ScrollTopAndComment = () => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowScroll = () => {
|
||||
if (window.scrollY > 50) setShow(true)
|
||||
else setShow(false)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleWindowScroll)
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll)
|
||||
}, [])
|
||||
|
||||
const handleScrollTop = () => {
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
const handleScrollToComment = () => {
|
||||
document.getElementById('comment')?.scrollIntoView()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
|
||||
>
|
||||
{siteMetadata.comments?.provider && (
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Scroll To Top"
|
||||
onClick={handleScrollTop}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollTopAndComment
|
35
components/SearchButton.tsx
Normal file
35
components/SearchButton.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { AlgoliaButton } from 'pliny/search/AlgoliaButton'
|
||||
import { KBarButton } from 'pliny/search/KBarButton'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const SearchButton = () => {
|
||||
if (
|
||||
siteMetadata.search &&
|
||||
(siteMetadata.search.provider === 'algolia' || siteMetadata.search.provider === 'kbar')
|
||||
) {
|
||||
const SearchButtonWrapper =
|
||||
siteMetadata.search.provider === 'algolia' ? AlgoliaButton : KBarButton
|
||||
|
||||
return (
|
||||
<SearchButtonWrapper aria-label="Search">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6 text-gray-900 hover:text-primary-500 dark:text-gray-100
|
||||
dark:hover:text-primary-400"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
</SearchButtonWrapper>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchButton
|
11
components/SectionContainer.tsx
Normal file
11
components/SectionContainer.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function SectionContainer({ children }: Props) {
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</section>
|
||||
)
|
||||
}
|
9
components/TableWrapper.tsx
Normal file
9
components/TableWrapper.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
const TableWrapper = ({ children }) => {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TableWrapper
|
18
components/Tag.tsx
Normal file
18
components/Tag.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import { slug } from 'github-slugger'
|
||||
interface Props {
|
||||
text: string
|
||||
}
|
||||
|
||||
const Tag = ({ text }: Props) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/tags/${slug(text)}`}
|
||||
className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
{text.split(' ').join('-')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tag
|
133
components/ThemeSwitch.tsx
Normal file
133
components/ThemeSwitch.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Menu, RadioGroup, Transition } from '@headlessui/react'
|
||||
|
||||
const Sun = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="group:hover:text-gray-100 h-6 w-6"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
const Moon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="group:hover:text-gray-100 h-6 w-6"
|
||||
>
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
)
|
||||
const Monitor = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="group:hover:text-gray-100 h-6 w-6"
|
||||
>
|
||||
<rect x="3" y="3" width="14" height="10" rx="2" ry="2"></rect>
|
||||
<line x1="7" y1="17" x2="13" y2="17"></line>
|
||||
<line x1="10" y1="13" x2="10" y2="17"></line>
|
||||
</svg>
|
||||
)
|
||||
const Blank = () => <svg className="h-6 w-6" />
|
||||
|
||||
const ThemeSwitch = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
|
||||
// When mounted on client, now we can show the UI
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
return (
|
||||
<div className="mr-5 flex items-center">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div className="flex items-center justify-center hover:text-primary-500 dark:hover:text-primary-400">
|
||||
<Menu.Button aria-label="Theme switcher">
|
||||
{mounted ? resolvedTheme === 'dark' ? <Moon /> : <Sun /> : <Blank />}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-32 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800">
|
||||
<RadioGroup value={theme} onChange={setTheme}>
|
||||
<div className="p-1">
|
||||
<RadioGroup.Option value="light">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${
|
||||
active ? 'bg-primary-600 text-white' : ''
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
>
|
||||
<div className="mr-2">
|
||||
<Sun />
|
||||
</div>
|
||||
Light
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="dark">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${
|
||||
active ? 'bg-primary-600 text-white' : ''
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
>
|
||||
<div className="mr-2">
|
||||
<Moon />
|
||||
</div>
|
||||
Dark
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="system">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${
|
||||
active ? 'bg-primary-600 text-white' : ''
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
>
|
||||
<div className="mr-2">
|
||||
<Monitor />
|
||||
</div>
|
||||
System
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</RadioGroup.Option>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSwitch
|
95
components/social-icons/icons.tsx
Normal file
95
components/social-icons/icons.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
// Icons taken from: https://simpleicons.org/
|
||||
// To add a new icon, add a new function here and add it to components in social-icons/index.tsx
|
||||
|
||||
export function Facebook(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>Facebook</title>
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Github(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Linkedin(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>Linkedin</title>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Mail(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" {...svgProps}>
|
||||
<title>Mail</title>
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Twitter(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>Twitter</title>
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function X(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>X</title>
|
||||
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Youtube(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>Youtube</title>
|
||||
<path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Mastodon(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>Mastodon</title>
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Threads(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>Threads</title>
|
||||
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Instagram(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<title>Instagram</title>
|
||||
<path d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
57
components/social-icons/index.tsx
Normal file
57
components/social-icons/index.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import {
|
||||
Mail,
|
||||
Github,
|
||||
Facebook,
|
||||
Youtube,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
X,
|
||||
Mastodon,
|
||||
Threads,
|
||||
Instagram,
|
||||
} from './icons'
|
||||
|
||||
const components = {
|
||||
mail: Mail,
|
||||
github: Github,
|
||||
facebook: Facebook,
|
||||
youtube: Youtube,
|
||||
linkedin: Linkedin,
|
||||
twitter: Twitter,
|
||||
x: X,
|
||||
mastodon: Mastodon,
|
||||
threads: Threads,
|
||||
instagram: Instagram,
|
||||
}
|
||||
|
||||
type SocialIconProps = {
|
||||
kind: keyof typeof components
|
||||
href: string | undefined
|
||||
size?: number
|
||||
}
|
||||
|
||||
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
|
||||
if (
|
||||
!href ||
|
||||
(kind === 'mail' && !/^mailto:[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(href))
|
||||
)
|
||||
return null
|
||||
|
||||
const SocialSvg = components[kind]
|
||||
|
||||
return (
|
||||
<a
|
||||
className="text-sm text-gray-500 transition hover:text-gray-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={href}
|
||||
>
|
||||
<span className="sr-only">{kind}</span>
|
||||
<SocialSvg
|
||||
className={`fill-current text-gray-700 hover:text-primary-500 dark:text-gray-200 dark:hover:text-primary-400 h-${size} w-${size}`}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default SocialIcon
|
Reference in New Issue
Block a user