feat: 更新博客框架到 v2。 (#3)
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 10m33s
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 10m33s
Co-authored-by: Ivan Li <ivanli2048@gmail.com> Reviewed-on: #3
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import Image from './Image';
|
||||
import Link from './Link';
|
||||
import Image from './Image'
|
||||
import Link from './Link'
|
||||
|
||||
const Card = ({ title, description, imgSrc, href }) => (
|
||||
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
||||
<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`}>
|
||||
} 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}`}>
|
||||
@@ -37,20 +38,19 @@ const Card = ({ title, description, imgSrc, href }) => (
|
||||
title
|
||||
)}
|
||||
</h2>
|
||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<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}`}>
|
||||
aria-label={`Link to ${title}`}
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
export default Card;
|
||||
export default Card
|
||||
|
@@ -1,23 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import Router from 'next/router';
|
||||
|
||||
/**
|
||||
* Client-side complement to next-remote-watch
|
||||
* Re-triggers getStaticProps when watched mdx files change
|
||||
*
|
||||
*/
|
||||
export const ClientReload = () => {
|
||||
// Exclude socket.io from prod bundle
|
||||
useEffect(() => {
|
||||
import('socket.io-client').then((module) => {
|
||||
const socket = module.io();
|
||||
socket.on('reload', () => {
|
||||
Router.replace(Router.asPath, undefined, {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
17
components/Comments.tsx
Normal file
17
components/Comments.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'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)
|
||||
return (
|
||||
<>
|
||||
{!loadComments && <button onClick={() => setLoadComments(true)}>Load Comments</button>}
|
||||
{siteMetadata.comments && loadComments && (
|
||||
<CommentsComponent commentsConfig={siteMetadata.comments} slug={slug} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -1,17 +1,13 @@
|
||||
import Link from './Link';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import SocialIcon from '@/components/social-icons';
|
||||
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="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} />
|
||||
@@ -31,13 +27,11 @@ export default function Footer() {
|
||||
</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"
|
||||
rel="nofollow">
|
||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
||||
Tailwind Nextjs Theme
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
48
components/Header.tsx
Normal file
48
components/Header.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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 = () => {
|
||||
return (
|
||||
<header className="flex items-center justify-between py-10">
|
||||
<div>
|
||||
<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>
|
||||
<div className="flex items-center leading-5 space-x-4 sm:space-x-6">
|
||||
{headerNavLinks
|
||||
.filter((link) => link.href !== '/')
|
||||
.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="hidden sm:block font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
<SearchButton />
|
||||
<ThemeSwitch />
|
||||
<MobileNav />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
@@ -1,5 +1,5 @@
|
||||
import NextImage, { ImageProps } from 'next/image';
|
||||
import NextImage, { ImageProps } from 'next/image'
|
||||
|
||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />;
|
||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />
|
||||
|
||||
export default Image;
|
||||
export default Image
|
||||
|
@@ -1,58 +1,27 @@
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
import Logo from '@/data/logo.svg';
|
||||
import Link from './Link';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import Footer from './Footer';
|
||||
import MobileNav from './MobileNav';
|
||||
import ThemeSwitch from './ThemeSwitch';
|
||||
import { ReactNode } from 'react';
|
||||
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;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
const LayoutWrapper = ({ children }: Props) => {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div className="flex h-screen flex-col justify-between">
|
||||
<header className="flex items-center justify-between py-10">
|
||||
<div>
|
||||
<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>
|
||||
<div className="flex items-center text-base leading-5">
|
||||
<div className="hidden sm:block">
|
||||
{headerNavLinks.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4">
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
<MobileNav />
|
||||
</div>
|
||||
</header>
|
||||
<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;
|
||||
export default LayoutWrapper
|
||||
|
@@ -1,30 +1,21 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import Link from 'next/link';
|
||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
|
||||
import Link from 'next/link'
|
||||
import type { LinkProps } from 'next/link'
|
||||
import { AnchorHTMLAttributes } from 'react'
|
||||
|
||||
const CustomLink = ({
|
||||
href,
|
||||
...rest
|
||||
}: DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>) => {
|
||||
const isInternalLink = href && href.startsWith('/');
|
||||
const isAnchorLink = href && href.startsWith('#');
|
||||
const CustomLink = ({ href, ...rest }: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const isInternalLink = href && href.startsWith('/')
|
||||
const isAnchorLink = href && href.startsWith('#')
|
||||
|
||||
if (isInternalLink) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a {...rest} />
|
||||
</Link>
|
||||
);
|
||||
return <Link href={href} {...rest} />
|
||||
}
|
||||
|
||||
if (isAnchorLink) {
|
||||
return <a href={href} {...rest} />;
|
||||
return <a href={href} {...rest} />
|
||||
}
|
||||
|
||||
return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />;
|
||||
};
|
||||
return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />
|
||||
}
|
||||
|
||||
export default CustomLink;
|
||||
export default CustomLink
|
||||
|
@@ -1,39 +1,14 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import React, { useMemo } from 'react';
|
||||
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
|
||||
import Image from './Image';
|
||||
import CustomLink from './Link';
|
||||
import TOCInline from './TOCInline';
|
||||
import Pre from './Pre';
|
||||
import { BlogNewsletterForm } from './NewsletterForm';
|
||||
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'
|
||||
|
||||
const Wrapper: React.ComponentType<{ layout: string }> = ({
|
||||
layout,
|
||||
...rest
|
||||
}) => {
|
||||
const Layout = require(`../layouts/${layout}`).default;
|
||||
return <Layout {...rest} />;
|
||||
};
|
||||
|
||||
export const MDXComponents: ComponentMap = {
|
||||
export const components: MDXComponents = {
|
||||
Image,
|
||||
//@ts-ignore
|
||||
TOCInline,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
wrapper: Wrapper,
|
||||
//@ts-ignore
|
||||
BlogNewsletterForm,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
layout: string;
|
||||
mdxSource: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
|
||||
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]);
|
||||
|
||||
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />;
|
||||
};
|
||||
|
@@ -1,73 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import Link from './Link';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from './Link'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
|
||||
const MobileNav = () => {
|
||||
const [navShow, setNavShow] = useState(false);
|
||||
const [navShow, setNavShow] = useState(false)
|
||||
|
||||
const onToggleNav = () => {
|
||||
setNavShow((status) => {
|
||||
if (status) {
|
||||
document.body.style.overflow = 'auto';
|
||||
document.body.style.overflow = 'auto'
|
||||
} else {
|
||||
// Prevent scrolling
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return !status;
|
||||
});
|
||||
};
|
||||
return !status
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}>
|
||||
<>
|
||||
<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="text-gray-900 dark:text-gray-100">
|
||||
{navShow ? (
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
className="text-gray-900 dark:text-gray-100 h-8 w-8"
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
className={`fixed top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
|
||||
className={`fixed left-0 top-0 z-10 h-full w-full transform opacity-95 dark:opacity-[0.98] bg-white duration-300 ease-in-out dark:bg-gray-950 ${
|
||||
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle modal"
|
||||
className="fixed h-full w-full cursor-auto focus:outline-none"
|
||||
onClick={onToggleNav}></button>
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<button className="mr-8 mt-11 h-8 w-8" aria-label="Toggle Menu" onClick={onToggleNav}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<nav className="fixed mt-8 h-full">
|
||||
{headerNavLinks.map((link) => (
|
||||
<div key={link.title} className="px-12 py-4">
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||||
onClick={onToggleNav}>
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNav;
|
||||
export default MobileNav
|
||||
|
@@ -1,93 +0,0 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
|
||||
const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {
|
||||
body: JSON.stringify({
|
||||
email: inputEl.current.value,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const { error } = await res.json();
|
||||
if (error) {
|
||||
setError(true);
|
||||
setMessage(
|
||||
'Your e-mail address is invalid or you are already subscribed!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
inputEl.current.value = '';
|
||||
setError(false);
|
||||
setSubscribed(true);
|
||||
setMessage('Successfully! 🎉 You are now subscribed.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-1 text-lg font-semibold text-gray-800 dark:text-gray-100">
|
||||
{title}
|
||||
</div>
|
||||
<form className="flex flex-col sm:flex-row" onSubmit={subscribe}>
|
||||
<div>
|
||||
<label className="sr-only" htmlFor="email-input">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="w-72 rounded-md px-4 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600 dark:bg-black"
|
||||
id="email-input"
|
||||
name="email"
|
||||
placeholder={
|
||||
subscribed ? "You're subscribed ! 🎉" : 'Enter your email'
|
||||
}
|
||||
ref={inputEl}
|
||||
required
|
||||
type="email"
|
||||
disabled={subscribed}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex w-full rounded-md shadow-sm sm:mt-0 sm:ml-3">
|
||||
<button
|
||||
className={`w-full rounded-md bg-primary-500 py-2 px-4 font-medium text-white sm:py-0 ${
|
||||
subscribed
|
||||
? 'cursor-default'
|
||||
: 'hover:bg-primary-700 dark:hover:bg-primary-400'
|
||||
} focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2 dark:ring-offset-black`}
|
||||
type="submit"
|
||||
disabled={subscribed}>
|
||||
{subscribed ? 'Thank you!' : 'Sign up'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="w-72 pt-2 text-sm text-red-500 dark:text-red-400 sm:w-96">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterForm;
|
||||
|
||||
export const BlogNewsletterForm = ({ title }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="bg-gray-100 p-6 dark:bg-gray-800 sm:px-14 sm:py-8">
|
||||
<NewsletterForm title={title} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function PageTitle({ children }: Props) {
|
||||
@@ -9,5 +9,5 @@ export default function PageTitle({ children }: Props) {
|
||||
<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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@@ -1,48 +0,0 @@
|
||||
import Link from '@/components/Link';
|
||||
|
||||
interface Props {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ totalPages, currentPage }: Props) {
|
||||
const prevPage = currentPage - 1 > 0;
|
||||
const nextPage = currentPage + 1 <= totalPages;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<nav className="flex justify-between">
|
||||
{!prevPage && (
|
||||
<button
|
||||
className="cursor-auto disabled:opacity-50"
|
||||
disabled={!prevPage}>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{prevPage && (
|
||||
<Link
|
||||
href={
|
||||
currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`
|
||||
}>
|
||||
<button>Previous</button>
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
{!nextPage && (
|
||||
<button
|
||||
className="cursor-auto disabled:opacity-50"
|
||||
disabled={!nextPage}>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
{nextPage && (
|
||||
<Link href={`/blog/page/${currentPage + 1}`}>
|
||||
<button>Next</button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,77 +0,0 @@
|
||||
import { useState, useRef, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Pre = ({ children }: Props) => {
|
||||
const textInput = useRef(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onEnter = () => {
|
||||
setHovered(true);
|
||||
};
|
||||
const onExit = () => {
|
||||
setHovered(false);
|
||||
setCopied(false);
|
||||
};
|
||||
const onCopy = () => {
|
||||
setCopied(true);
|
||||
navigator.clipboard.writeText(textInput.current.textContent);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={textInput}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onExit}
|
||||
className="relative">
|
||||
{hovered && (
|
||||
<button
|
||||
aria-label="Copy code"
|
||||
type="button"
|
||||
className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
|
||||
copied
|
||||
? 'border-green-400 focus:border-green-400 focus:outline-none'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
onClick={onCopy}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
className={copied ? 'text-green-400' : 'text-gray-300'}>
|
||||
{copied ? (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<pre>{children}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pre;
|
@@ -1,208 +0,0 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface CommonSEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
ogType: string;
|
||||
ogImage:
|
||||
| string
|
||||
| {
|
||||
'@type': string;
|
||||
url: string;
|
||||
}[];
|
||||
twImage: string;
|
||||
canonicalUrl?: string;
|
||||
}
|
||||
|
||||
const CommonSEO = ({
|
||||
title,
|
||||
description,
|
||||
ogType,
|
||||
ogImage,
|
||||
twImage,
|
||||
canonicalUrl,
|
||||
}: CommonSEOProps) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta name="robots" content="follow, index" />
|
||||
<meta name="description" content={description} />
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${siteMetadata.siteUrl}${router.asPath}`}
|
||||
/>
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content={siteMetadata.title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:title" content={title} />
|
||||
{Array.isArray(ogImage) ? (
|
||||
ogImage.map(({ url }) => (
|
||||
<meta property="og:image" content={url} key={url} />
|
||||
))
|
||||
) : (
|
||||
<meta property="og:image" content={ogImage} key={ogImage} />
|
||||
)}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content={siteMetadata.twitter} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={twImage} />
|
||||
<link
|
||||
rel="canonical"
|
||||
href={
|
||||
canonicalUrl
|
||||
? canonicalUrl
|
||||
: `${siteMetadata.siteUrl}${router.asPath}`
|
||||
}
|
||||
/>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageSEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const PageSEO = ({ title, description }: PageSEOProps) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
return (
|
||||
<CommonSEO
|
||||
title={title}
|
||||
description={description}
|
||||
ogType="website"
|
||||
ogImage={ogImageUrl}
|
||||
twImage={twImageUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagSEO = ({ title, description }: PageSEOProps) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<CommonSEO
|
||||
title={title}
|
||||
description={description}
|
||||
ogType="website"
|
||||
ogImage={ogImageUrl}
|
||||
twImage={twImageUrl}
|
||||
/>
|
||||
<Head>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={`${description} - RSS feed`}
|
||||
href={`${siteMetadata.siteUrl}${router.asPath}/feed.xml`}
|
||||
/>
|
||||
</Head>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface BlogSeoProps extends PostFrontMatter {
|
||||
authorDetails?: AuthorFrontMatter[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const BlogSEO = ({
|
||||
authorDetails,
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
lastmod,
|
||||
url,
|
||||
images = [],
|
||||
canonicalUrl,
|
||||
}: BlogSeoProps) => {
|
||||
const publishedAt = new Date(date).toISOString();
|
||||
const modifiedAt = new Date(lastmod || date).toISOString();
|
||||
const imagesArr =
|
||||
images.length === 0
|
||||
? [siteMetadata.socialBanner]
|
||||
: typeof images === 'string'
|
||||
? [images]
|
||||
: images;
|
||||
|
||||
const featuredImages = imagesArr.map((img) => {
|
||||
return {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteMetadata.siteUrl}${img}`,
|
||||
};
|
||||
});
|
||||
|
||||
let authorList;
|
||||
if (authorDetails) {
|
||||
authorList = authorDetails.map((author) => {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
authorList = {
|
||||
'@type': 'Person',
|
||||
name: siteMetadata.author,
|
||||
};
|
||||
}
|
||||
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url,
|
||||
},
|
||||
headline: title,
|
||||
image: featuredImages,
|
||||
datePublished: publishedAt,
|
||||
dateModified: modifiedAt,
|
||||
author: authorList,
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: siteMetadata.author,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteMetadata.siteUrl}${siteMetadata.siteLogo}`,
|
||||
},
|
||||
},
|
||||
description: summary,
|
||||
};
|
||||
|
||||
const twImageUrl = featuredImages[0].url;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommonSEO
|
||||
title={title}
|
||||
description={summary}
|
||||
ogType="article"
|
||||
ogImage={featuredImages}
|
||||
twImage={twImageUrl}
|
||||
canonicalUrl={canonicalUrl}
|
||||
/>
|
||||
<Head>
|
||||
{date && (
|
||||
<meta property="article:published_time" content={publishedAt} />
|
||||
)}
|
||||
{lastmod && (
|
||||
<meta property="article:modified_time" content={modifiedAt} />
|
||||
)}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(structuredData, null, 2),
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
</>
|
||||
);
|
||||
};
|
@@ -1,47 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
'use client'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const ScrollTopAndComment = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowScroll = () => {
|
||||
if (window.scrollY > 50) setShow(true);
|
||||
else setShow(false);
|
||||
};
|
||||
if (window.scrollY > 50) setShow(true)
|
||||
else setShow(false)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleWindowScroll);
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll);
|
||||
}, []);
|
||||
window.addEventListener('scroll', handleWindowScroll)
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll)
|
||||
}, [])
|
||||
|
||||
const handleScrollTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
const handleScrollToComment = () => {
|
||||
document.getElementById('comment').scrollIntoView();
|
||||
};
|
||||
document.getElementById('comment')?.scrollIntoView()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${
|
||||
show ? 'md:flex' : 'md:hidden'
|
||||
}`}>
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
type="button"
|
||||
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>
|
||||
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"
|
||||
type="button"
|
||||
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">
|
||||
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"
|
||||
@@ -51,7 +55,7 @@ const ScrollTopAndComment = () => {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollTopAndComment;
|
||||
export default ScrollTopAndComment
|
||||
|
34
components/SearchButton.tsx
Normal file
34
components/SearchButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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="text-gray-900 dark:text-gray-100 h-6 w-6"
|
||||
>
|
||||
<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
|
@@ -1,13 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function SectionContainer({ children }: Props) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
<section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</section>
|
||||
)
|
||||
}
|
||||
|
@@ -1,74 +0,0 @@
|
||||
import { Toc } from 'types/Toc';
|
||||
|
||||
interface TOCInlineProps {
|
||||
toc: Toc;
|
||||
indentDepth?: number;
|
||||
fromHeading?: number;
|
||||
toHeading?: number;
|
||||
asDisclosure?: boolean;
|
||||
exclude?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an inline table of contents
|
||||
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
|
||||
* If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
|
||||
*
|
||||
* @param {TOCInlineProps} {
|
||||
* toc,
|
||||
* indentDepth = 3,
|
||||
* fromHeading = 1,
|
||||
* toHeading = 6,
|
||||
* asDisclosure = false,
|
||||
* exclude = '',
|
||||
* }
|
||||
*
|
||||
*/
|
||||
const TOCInline = ({
|
||||
toc,
|
||||
indentDepth = 3,
|
||||
fromHeading = 1,
|
||||
toHeading = 6,
|
||||
asDisclosure = false,
|
||||
exclude = '',
|
||||
}: TOCInlineProps) => {
|
||||
const re = Array.isArray(exclude)
|
||||
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
|
||||
: new RegExp('^(' + exclude + ')$', 'i');
|
||||
|
||||
const filteredToc = toc.filter(
|
||||
(heading) =>
|
||||
heading.depth >= fromHeading &&
|
||||
heading.depth <= toHeading &&
|
||||
!re.test(heading.value)
|
||||
);
|
||||
|
||||
const tocList = (
|
||||
<ul>
|
||||
{filteredToc.map((heading) => (
|
||||
<li
|
||||
key={heading.value}
|
||||
className={`${heading.depth >= indentDepth && 'ml-6'}`}>
|
||||
<a href={heading.url}>{heading.value}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{asDisclosure ? (
|
||||
<details open>
|
||||
<summary className="ml-6 pt-2 pb-2 text-xl font-bold">
|
||||
Table of Contents
|
||||
</summary>
|
||||
<div className="ml-6">{tocList}</div>
|
||||
</details>
|
||||
) : (
|
||||
tocList
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TOCInline;
|
@@ -1,18 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
import kebabCase from '@/lib/utils/kebabCase';
|
||||
|
||||
import Link from 'next/link'
|
||||
import { slug } from 'github-slugger'
|
||||
interface Props {
|
||||
text: string;
|
||||
text: string
|
||||
}
|
||||
|
||||
const Tag = ({ text }: Props) => {
|
||||
return (
|
||||
<Link href={`/tags/${kebabCase(text)}`}>
|
||||
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{text.split(' ').join('-')}
|
||||
</a>
|
||||
<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;
|
||||
export default Tag
|
||||
|
@@ -1,29 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
const ThemeSwitch = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
// When mounted on client, now we can show the UI
|
||||
useEffect(() => setMounted(true), []);
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Toggle Dark Mode"
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
|
||||
onClick={() =>
|
||||
setTheme(
|
||||
theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||
)
|
||||
}>
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||
className="text-gray-900 dark:text-gray-100 h-6 w-6"
|
||||
>
|
||||
{mounted && theme === 'dark' ? (
|
||||
<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"
|
||||
@@ -34,7 +36,7 @@ const ThemeSwitch = () => {
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSwitch;
|
||||
export default ThemeSwitch
|
||||
|
@@ -1,36 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const GAScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
strategy="lazyOnload"
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${siteMetadata.analytics.googleAnalyticsId}`}
|
||||
/>
|
||||
|
||||
<Script strategy="lazyOnload" id="ga-script">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${siteMetadata.analytics.googleAnalyticsId}', {
|
||||
page_path: window.location.pathname,
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GAScript;
|
||||
|
||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||
export const logEvent = (action, category, label, value) => {
|
||||
window.gtag?.('event', action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value: value,
|
||||
});
|
||||
};
|
@@ -1,27 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const PlausibleScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
strategy="lazyOnload"
|
||||
data-domain={siteMetadata.analytics.plausibleDataDomain}
|
||||
src="https://plausible.io/js/plausible.js"
|
||||
/>
|
||||
<Script strategy="lazyOnload" id="plausible-script">
|
||||
{`
|
||||
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlausibleScript;
|
||||
|
||||
// https://plausible.io/docs/custom-event-goals
|
||||
export const logEvent = (eventName, ...rest) => {
|
||||
return window.plausible?.(eventName, ...rest);
|
||||
};
|
@@ -1,28 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
const SimpleAnalyticsScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script strategy="lazyOnload" id="sa-script">
|
||||
{`
|
||||
window.sa_event=window.sa_event||function(){var a=[].slice.call(arguments);window.sa_event.q?window.sa_event.q.push(a):window.sa_event.q=[a]};
|
||||
`}
|
||||
</Script>
|
||||
<Script
|
||||
strategy="lazyOnload"
|
||||
src="https://scripts.simpleanalyticscdn.com/latest.js"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// https://docs.simpleanalytics.com/events
|
||||
export const logEvent = (eventName, callback) => {
|
||||
if (callback) {
|
||||
return window.sa_event?.(eventName, callback);
|
||||
} else {
|
||||
return window.sa_event?.(eventName);
|
||||
}
|
||||
};
|
||||
|
||||
export default SimpleAnalyticsScript;
|
@@ -1,18 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const UmamiScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
async
|
||||
defer
|
||||
data-website-id={siteMetadata.analytics.umamiWebsiteId}
|
||||
src="https://umami.example.com/umami.js" // Replace with your umami instance
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UmamiScript;
|
@@ -1,32 +0,0 @@
|
||||
import GA from './GoogleAnalytics';
|
||||
import Plausible from './Plausible';
|
||||
import SimpleAnalytics from './SimpleAnalytics';
|
||||
import Umami from './Umami';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (...args: any[]) => void;
|
||||
plausible?: (...args: any[]) => void;
|
||||
sa_event?: (...args: any[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const Analytics = () => {
|
||||
return (
|
||||
<>
|
||||
{isProduction && siteMetadata.analytics.plausibleDataDomain && (
|
||||
<Plausible />
|
||||
)}
|
||||
{isProduction && siteMetadata.analytics.simpleAnalytics && (
|
||||
<SimpleAnalytics />
|
||||
)}
|
||||
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
|
||||
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
@@ -1,33 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { useTheme } from 'next-themes';
|
||||
import ReactCommento from './commento/ReactCommento';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const Commento = ({ frontMatter }: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const commentsTheme = useMemo(() => {
|
||||
switch (resolvedTheme) {
|
||||
case 'light':
|
||||
case 'dark':
|
||||
return resolvedTheme;
|
||||
default:
|
||||
return 'auto';
|
||||
}
|
||||
}, [resolvedTheme]);
|
||||
return (
|
||||
<div className="my-2">
|
||||
<ReactCommento
|
||||
url={siteMetadata.comment.commentoConfig.url}
|
||||
pageId={frontMatter.slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Commento;
|
@@ -1,51 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const Disqus = ({ frontMatter }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
|
||||
const COMMENTS_ID = 'disqus_thread';
|
||||
|
||||
function LoadComments() {
|
||||
setEnabledLoadComments(false);
|
||||
|
||||
// @ts-ignore
|
||||
window.disqus_config = function () {
|
||||
this.page.url = window.location.href;
|
||||
this.page.identifier = frontMatter.slug;
|
||||
};
|
||||
// @ts-ignore
|
||||
if (window.DISQUS === undefined) {
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
'https://' +
|
||||
siteMetadata.comment.disqusConfig.shortname +
|
||||
'.disqus.com/embed.js';
|
||||
// @ts-ignore
|
||||
script.setAttribute('data-timestamp', +new Date());
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.DISQUS.reset({ reload: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="disqus-frame" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Disqus;
|
@@ -1,78 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
interface Props {
|
||||
mapping: string;
|
||||
}
|
||||
|
||||
const Giscus = ({ mapping }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const commentsTheme =
|
||||
siteMetadata.comment.giscusConfig.themeURL === ''
|
||||
? theme === 'dark' || resolvedTheme === 'dark'
|
||||
? siteMetadata.comment.giscusConfig.darkTheme
|
||||
: siteMetadata.comment.giscusConfig.theme
|
||||
: siteMetadata.comment.giscusConfig.themeURL;
|
||||
|
||||
const COMMENTS_ID = 'comments-container';
|
||||
|
||||
const LoadComments = useCallback(() => {
|
||||
setEnabledLoadComments(false);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://giscus.app/client.js';
|
||||
script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo);
|
||||
script.setAttribute(
|
||||
'data-repo-id',
|
||||
siteMetadata.comment.giscusConfig.repositoryId
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-category',
|
||||
siteMetadata.comment.giscusConfig.category
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-category-id',
|
||||
siteMetadata.comment.giscusConfig.categoryId
|
||||
);
|
||||
script.setAttribute('data-mapping', mapping);
|
||||
script.setAttribute(
|
||||
'data-reactions-enabled',
|
||||
siteMetadata.comment.giscusConfig.reactions
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-emit-metadata',
|
||||
siteMetadata.comment.giscusConfig.metadata
|
||||
);
|
||||
script.setAttribute('data-theme', commentsTheme);
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.innerHTML = '';
|
||||
};
|
||||
}, [commentsTheme, mapping]);
|
||||
|
||||
// Reload on theme change
|
||||
useEffect(() => {
|
||||
const iframe = document.querySelector('iframe.giscus-frame');
|
||||
if (!iframe) return;
|
||||
LoadComments();
|
||||
}, [LoadComments]);
|
||||
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="giscus" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Giscus;
|
@@ -1,58 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
interface Props {
|
||||
issueTerm: string;
|
||||
}
|
||||
|
||||
const Utterances = ({ issueTerm }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const commentsTheme =
|
||||
theme === 'dark' || resolvedTheme === 'dark'
|
||||
? siteMetadata.comment.utterancesConfig.darkTheme
|
||||
: siteMetadata.comment.utterancesConfig.theme;
|
||||
|
||||
const COMMENTS_ID = 'comments-container';
|
||||
|
||||
const LoadComments = useCallback(() => {
|
||||
setEnabledLoadComments(false);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://utteranc.es/client.js';
|
||||
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo);
|
||||
script.setAttribute('issue-term', issueTerm);
|
||||
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label);
|
||||
script.setAttribute('theme', commentsTheme);
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.innerHTML = '';
|
||||
};
|
||||
}, [commentsTheme, issueTerm]);
|
||||
|
||||
// Reload on theme change
|
||||
useEffect(() => {
|
||||
const iframe = document.querySelector('iframe.utterances-frame');
|
||||
if (!iframe) return;
|
||||
LoadComments();
|
||||
}, [LoadComments]);
|
||||
|
||||
// Added `relative` to fix a weird bug with `utterances-frame` position
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="utterances-frame relative" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Utterances;
|
@@ -1,93 +0,0 @@
|
||||
import { createRef } from 'preact';
|
||||
import React, { useLayoutEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface DataAttributes {
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
|
||||
const insertScript = (
|
||||
src: string,
|
||||
id: string,
|
||||
dataAttributes: DataAttributes,
|
||||
onload = () => {}
|
||||
) => {
|
||||
const script = window.document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = src;
|
||||
script.id = id;
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
script.addEventListener('load', onload, { capture: true, once: true });
|
||||
|
||||
Object.entries(dataAttributes).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
script.setAttribute(`data-${key}`, value.toString());
|
||||
});
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
script.remove();
|
||||
};
|
||||
};
|
||||
|
||||
const ReactCommento = ({
|
||||
url,
|
||||
cssOverride,
|
||||
autoInit,
|
||||
noFonts,
|
||||
hideDeleted,
|
||||
pageId,
|
||||
}: {
|
||||
url: string;
|
||||
cssOverride?: string;
|
||||
autoInit?: boolean;
|
||||
noFonts?: boolean;
|
||||
hideDeleted?: boolean;
|
||||
pageId?: string;
|
||||
}) => {
|
||||
const containerId = useMemo(
|
||||
() => `commento-${Math.random().toString().slice(2, 8)}`,
|
||||
[]
|
||||
);
|
||||
const container = createRef<HTMLDivElement>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
|
||||
window['commento'] = container.current;
|
||||
|
||||
const removeScript = insertScript(
|
||||
url,
|
||||
`${containerId}-script`,
|
||||
{
|
||||
'css-override': cssOverride,
|
||||
'auto-init': autoInit,
|
||||
'no-fonts': noFonts,
|
||||
'hide-deleted': hideDeleted,
|
||||
'page-id': pageId,
|
||||
'id-root': containerId,
|
||||
},
|
||||
() => {
|
||||
removeScript();
|
||||
}
|
||||
);
|
||||
}, [
|
||||
autoInit,
|
||||
cssOverride,
|
||||
hideDeleted,
|
||||
noFonts,
|
||||
pageId,
|
||||
url,
|
||||
containerId,
|
||||
container,
|
||||
]);
|
||||
|
||||
return <div ref={container} id={containerId} />;
|
||||
};
|
||||
export default ReactCommento;
|
@@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const UtterancesComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Utterances');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const GiscusComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Giscus');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const DisqusComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Disqus');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const CommentoComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Commento');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const Comments = ({ frontMatter }: Props) => {
|
||||
let term;
|
||||
switch (
|
||||
siteMetadata.comment.giscusConfig.mapping ||
|
||||
siteMetadata.comment.utterancesConfig.issueTerm
|
||||
) {
|
||||
case 'pathname':
|
||||
term = frontMatter.slug;
|
||||
break;
|
||||
case 'url':
|
||||
term = window.location.href;
|
||||
break;
|
||||
case 'title':
|
||||
term = frontMatter.title;
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<div id="comment">
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
|
||||
<GiscusComponent mapping={term} />
|
||||
)}
|
||||
{siteMetadata.comment &&
|
||||
siteMetadata.comment.provider === 'utterances' && (
|
||||
<UtterancesComponent issueTerm={term} />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
|
||||
<DisqusComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'commento' && (
|
||||
<CommentoComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Comments;
|
@@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Facebook icon</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"/></svg>
|
Before Width: | Height: | Size: 403 B |
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</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"/></svg>
|
Before Width: | Height: | Size: 827 B |
61
components/social-icons/icons.tsx
Normal file
61
components/social-icons/icons.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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 Youtube(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<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}>
|
||||
<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>
|
||||
)
|
||||
}
|
@@ -1,11 +1,4 @@
|
||||
import Mail from './mail.svg';
|
||||
import Github from './github.svg';
|
||||
import Facebook from './facebook.svg';
|
||||
import Youtube from './youtube.svg';
|
||||
import Linkedin from './linkedin.svg';
|
||||
import Twitter from './twitter.svg';
|
||||
|
||||
// Icons taken from: https://simpleicons.org/
|
||||
import { Mail, Github, Facebook, Youtube, Linkedin, Twitter, Mastodon } from './icons'
|
||||
|
||||
const components = {
|
||||
mail: Mail,
|
||||
@@ -14,30 +7,34 @@ const components = {
|
||||
youtube: Youtube,
|
||||
linkedin: Linkedin,
|
||||
twitter: Twitter,
|
||||
};
|
||||
mastodon: Mastodon,
|
||||
}
|
||||
|
||||
const SocialIcon = ({ kind, href, size = 8 }) => {
|
||||
if (
|
||||
!href ||
|
||||
(kind === 'mail' &&
|
||||
!/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))
|
||||
)
|
||||
return null;
|
||||
type SocialIconProps = {
|
||||
kind: keyof typeof components
|
||||
href: string | undefined
|
||||
size?: number
|
||||
}
|
||||
|
||||
const SocialSvg = components[kind];
|
||||
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
|
||||
if (!href || (kind === 'mail' && !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.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}>
|
||||
href={href}
|
||||
>
|
||||
<span className="sr-only">{kind}</span>
|
||||
<SocialSvg
|
||||
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
||||
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;
|
||||
export default SocialIcon
|
||||
|
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn icon</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.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 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"/></svg>
|
Before Width: | Height: | Size: 615 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
Before Width: | Height: | Size: 224 B |
@@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Twitter icon</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"/></svg>
|
Before Width: | Height: | Size: 607 B |
@@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>YouTube icon</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"/></svg>
|
Before Width: | Height: | Size: 474 B |
Reference in New Issue
Block a user