mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-06-05 19:15:30 -04:00
refactor: eslint/prettier ➡️ biome
This commit is contained in:
+6
-8
@@ -1,13 +1,11 @@
|
||||
import { Analytics as VercelAnalytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights as VercelSpeedInsights } from "@vercel/speed-insights/next";
|
||||
|
||||
const Analytics = () => {
|
||||
return (
|
||||
<>
|
||||
<VercelAnalytics />
|
||||
<VercelSpeedInsights />
|
||||
</>
|
||||
);
|
||||
};
|
||||
const Analytics = () => (
|
||||
<>
|
||||
<VercelAnalytics />
|
||||
<VercelSpeedInsights />
|
||||
</>
|
||||
);
|
||||
|
||||
export { Analytics };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
|
||||
+29
-31
@@ -1,9 +1,9 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import type { VideoObject } from "schema-dts";
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { Video } from "@/components/video";
|
||||
import { env } from "@/lib/env";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import type { VideoObject } from "schema-dts";
|
||||
|
||||
import thumbnail from "./thumbnail.png";
|
||||
|
||||
@@ -21,35 +21,33 @@ export const metadata = createMetadata({
|
||||
},
|
||||
});
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<JsonLd<VideoObject>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: metadata.title as string,
|
||||
description: metadata.description as string,
|
||||
contentUrl:
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/birthday-pavk1LBK4H6xF8ZWeR0oTcaabGuQ8T.webm",
|
||||
thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`,
|
||||
embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/birthday`,
|
||||
uploadDate: "1996-02-06T00:00:00Z",
|
||||
duration: "PT6M10S",
|
||||
}}
|
||||
/>
|
||||
|
||||
<PageTitle canonical="/birthday">1996.mov</PageTitle>
|
||||
|
||||
<Video
|
||||
src={[
|
||||
const Page = () => (
|
||||
<>
|
||||
<JsonLd<VideoObject>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: metadata.title as string,
|
||||
description: metadata.description as string,
|
||||
contentUrl:
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/birthday-pavk1LBK4H6xF8ZWeR0oTcaabGuQ8T.webm",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/birthday-EkbYbrKY8reheQ4UPcP22ipzpMZ2MC.mp4",
|
||||
]}
|
||||
poster={thumbnail.src}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`,
|
||||
embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/birthday`,
|
||||
uploadDate: "1996-02-06T00:00:00Z",
|
||||
duration: "PT6M10S",
|
||||
}}
|
||||
/>
|
||||
|
||||
<PageTitle canonical="/birthday">1996.mov</PageTitle>
|
||||
|
||||
<Video
|
||||
src={[
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/birthday-pavk1LBK4H6xF8ZWeR0oTcaabGuQ8T.webm",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/birthday-EkbYbrKY8reheQ4UPcP22ipzpMZ2MC.mp4",
|
||||
]}
|
||||
poster={thumbnail.src}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
+47
-39
@@ -1,51 +1,59 @@
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { ContactForm } from "@/components/contact-form";
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "Contact Me",
|
||||
description: "Fill out this quick form and I'll get back to you as soon as I can.",
|
||||
description:
|
||||
"Fill out this quick form and I'll get back to you as soon as I can.",
|
||||
canonical: "/contact",
|
||||
});
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<PageTitle canonical="/contact">Contact</PageTitle>
|
||||
const Page = () => (
|
||||
<>
|
||||
<PageTitle canonical="/contact">Contact</PageTitle>
|
||||
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
|
||||
<p>
|
||||
Fill out this quick form and I’ll get back to you as soon as I can! You can also{" "}
|
||||
<a href="mailto:jake@jarv.is">email me directly</a> or send me a direct message on{" "}
|
||||
<a href="https://bsky.app/profile/jarv.is" target="_blank" rel="noopener noreferrer">
|
||||
Bluesky
|
||||
</a>{" "}
|
||||
or{" "}
|
||||
<a href="https://fediverse.jarv.is/@jake" target="_blank" rel="noopener noreferrer">
|
||||
Mastodon
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
You can grab my public key here:{" "}
|
||||
<a
|
||||
href="https://keyoxide.org/hkp/3bc6e5776bf379d36f6714802b0c9cf251e69a39"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
|
||||
className="bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium tracking-wider [word-spacing:-0.25em]"
|
||||
>
|
||||
2B0C 9CF2 51E6 9A39
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContactForm />
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
|
||||
<p>
|
||||
Fill out this quick form and I’ll get back to you as soon as I
|
||||
can! You can also <a href="mailto:jake@jarv.is">email me directly</a>{" "}
|
||||
or send me a direct message on{" "}
|
||||
<a
|
||||
href="https://bsky.app/profile/jarv.is"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Bluesky
|
||||
</a>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
href="https://fediverse.jarv.is/@jake"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Mastodon
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
You can grab my public key here:{" "}
|
||||
<a
|
||||
href="https://keyoxide.org/hkp/3bc6e5776bf379d36f6714802b0c9cf251e69a39"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
|
||||
className="relative rounded-sm bg-muted px-[0.3rem] py-[0.2rem] font-medium font-mono text-sm tracking-wider [word-spacing:-0.25em]"
|
||||
>
|
||||
2B0C 9CF2 51E6 9A39
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
<ContactForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
+62
-48
@@ -1,15 +1,16 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import type { VideoObject } from "schema-dts";
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { Video } from "@/components/video";
|
||||
import { env } from "@/lib/env";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import type { VideoObject } from "schema-dts";
|
||||
|
||||
import thumbnail from "./thumbnail.png";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "My Brief Apperance in Hillary Clinton's DNC Video",
|
||||
description: "My brief apperance in one of Hillary Clinton's 2016 DNC convention videos on substance abuse.",
|
||||
description:
|
||||
"My brief apperance in one of Hillary Clinton's 2016 DNC convention videos on substance abuse.",
|
||||
canonical: "/hillary",
|
||||
openGraph: {
|
||||
videos: [
|
||||
@@ -21,52 +22,65 @@ export const metadata = createMetadata({
|
||||
},
|
||||
});
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<JsonLd<VideoObject>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: metadata.title as string,
|
||||
description: metadata.description as string,
|
||||
contentUrl:
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/convention-ZTUBLwMcmOE8EJ4tNAhpCli4NAHKcG.webm",
|
||||
thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`,
|
||||
embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/hillary`,
|
||||
uploadDate: "2016-07-25T00:00:00Z",
|
||||
duration: "PT1M51S",
|
||||
}}
|
||||
/>
|
||||
|
||||
<PageTitle canonical="/hillary">HRC.mov</PageTitle>
|
||||
|
||||
<Video
|
||||
src={[
|
||||
const Page = () => (
|
||||
<>
|
||||
<JsonLd<VideoObject>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: metadata.title as string,
|
||||
description: metadata.description as string,
|
||||
contentUrl:
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/convention-ZTUBLwMcmOE8EJ4tNAhpCli4NAHKcG.webm",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/convention-T6klrrArGL0IO4QPaloIiIH164UqUC.mp4",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/convention.en-uHnecgVCrT9xA8EkzdEaeIwB0rHFC9.vtt",
|
||||
]}
|
||||
poster={thumbnail.src}
|
||||
/>
|
||||
thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`,
|
||||
embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/hillary`,
|
||||
uploadDate: "2016-07-25T00:00:00Z",
|
||||
duration: "PT1M51S",
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground mx-4 mt-5 mb-0 text-center text-sm leading-relaxed">
|
||||
Video is property of{" "}
|
||||
<a href="https://www.hillaryclinton.com/" target="_blank" rel="noopener noreferrer" className="font-bold">
|
||||
Hillary for America
|
||||
</a>
|
||||
, the{" "}
|
||||
<a href="https://democrats.org/" target="_blank" rel="noopener noreferrer" className="font-bold">
|
||||
Democratic National Committee
|
||||
</a>
|
||||
, and{" "}
|
||||
<a href="https://cnnpressroom.blogs.cnn.com/" target="_blank" rel="noopener noreferrer" className="font-bold">
|
||||
CNN / WarnerMedia
|
||||
</a>
|
||||
. © 2016.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
<PageTitle canonical="/hillary">HRC.mov</PageTitle>
|
||||
|
||||
<Video
|
||||
src={[
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/convention-ZTUBLwMcmOE8EJ4tNAhpCli4NAHKcG.webm",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/convention-T6klrrArGL0IO4QPaloIiIH164UqUC.mp4",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/convention.en-uHnecgVCrT9xA8EkzdEaeIwB0rHFC9.vtt",
|
||||
]}
|
||||
poster={thumbnail.src}
|
||||
/>
|
||||
|
||||
<p className="mx-4 mt-5 mb-0 text-center text-muted-foreground text-sm leading-relaxed">
|
||||
Video is property of{" "}
|
||||
<a
|
||||
href="https://www.hillaryclinton.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold"
|
||||
>
|
||||
Hillary for America
|
||||
</a>
|
||||
, the{" "}
|
||||
<a
|
||||
href="https://democrats.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold"
|
||||
>
|
||||
Democratic National Committee
|
||||
</a>
|
||||
, and{" "}
|
||||
<a
|
||||
href="https://cnnpressroom.blogs.cnn.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold"
|
||||
>
|
||||
CNN / WarnerMedia
|
||||
</a>
|
||||
. © 2016.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
+67
-69
@@ -1,82 +1,80 @@
|
||||
import { ViewTransition } from "react";
|
||||
import { env } from "@/lib/env";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Analytics } from "@/app/analytics";
|
||||
import { defaultMetadata } from "@/lib/metadata";
|
||||
import { Inter, JetBrainsMono } from "@/lib/fonts";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import authorConfig from "@/lib/config/author";
|
||||
import type { Person, WebSite } from "schema-dts";
|
||||
import { Analytics } from "@/app/analytics";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import authorConfig from "@/lib/config/author";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import { env } from "@/lib/env";
|
||||
import { Inter, JetBrainsMono } from "@/lib/fonts";
|
||||
import { defaultMetadata } from "@/lib/metadata";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata = defaultMetadata;
|
||||
|
||||
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
return (
|
||||
<html
|
||||
lang={env.NEXT_PUBLIC_SITE_LOCALE}
|
||||
className={`${Inter.variable} ${JetBrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<JsonLd<Person>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"@id": `${env.NEXT_PUBLIC_BASE_URL}/#person`,
|
||||
name: authorConfig.name,
|
||||
url: env.NEXT_PUBLIC_BASE_URL,
|
||||
image: [`${env.NEXT_PUBLIC_BASE_URL}/opengraph-image.jpg`],
|
||||
sameAs: [
|
||||
env.NEXT_PUBLIC_BASE_URL,
|
||||
`https://${authorConfig.social?.mastodon}`,
|
||||
`https://github.com/${authorConfig.social?.github}`,
|
||||
`https://bsky.app/profile/${authorConfig.social?.bluesky}`,
|
||||
`https://twitter.com/${authorConfig.social?.twitter}`,
|
||||
`https://medium.com/@${authorConfig.social?.medium}`,
|
||||
`https://www.linkedin.com/in/${authorConfig.social?.linkedin}/`,
|
||||
`https://www.facebook.com/${authorConfig.social?.facebook}`,
|
||||
`https://www.instagram.com/${authorConfig.social?.instagram}/`,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => (
|
||||
<html
|
||||
lang={env.NEXT_PUBLIC_SITE_LOCALE}
|
||||
className={`${Inter.variable} ${JetBrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<JsonLd<Person>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"@id": `${env.NEXT_PUBLIC_BASE_URL}/#person`,
|
||||
name: authorConfig.name,
|
||||
url: env.NEXT_PUBLIC_BASE_URL,
|
||||
image: [`${env.NEXT_PUBLIC_BASE_URL}/opengraph-image.jpg`],
|
||||
sameAs: [
|
||||
env.NEXT_PUBLIC_BASE_URL,
|
||||
`https://${authorConfig.social?.mastodon}`,
|
||||
`https://github.com/${authorConfig.social?.github}`,
|
||||
`https://bsky.app/profile/${authorConfig.social?.bluesky}`,
|
||||
`https://twitter.com/${authorConfig.social?.twitter}`,
|
||||
`https://medium.com/@${authorConfig.social?.medium}`,
|
||||
`https://www.linkedin.com/in/${authorConfig.social?.linkedin}/`,
|
||||
`https://www.facebook.com/${authorConfig.social?.facebook}`,
|
||||
`https://www.instagram.com/${authorConfig.social?.instagram}/`,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<JsonLd<WebSite>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": `${env.NEXT_PUBLIC_BASE_URL}/#website`,
|
||||
name: siteConfig.name,
|
||||
url: env.NEXT_PUBLIC_BASE_URL,
|
||||
author: authorConfig.name,
|
||||
description: siteConfig.description,
|
||||
inLanguage: env.NEXT_PUBLIC_SITE_LOCALE,
|
||||
license: `https://spdx.org/licenses/${siteConfig.license}.html`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<JsonLd<WebSite>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": `${env.NEXT_PUBLIC_BASE_URL}/#website`,
|
||||
name: siteConfig.name,
|
||||
url: env.NEXT_PUBLIC_BASE_URL,
|
||||
author: authorConfig.name,
|
||||
description: siteConfig.description,
|
||||
inLanguage: env.NEXT_PUBLIC_SITE_LOCALE,
|
||||
license: `https://spdx.org/licenses/${siteConfig.license}.html`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body className="bg-background text-foreground font-sans antialiased">
|
||||
<Providers>
|
||||
<Header />
|
||||
<div className="mx-auto mt-4 w-full max-w-4xl px-5">
|
||||
<main>
|
||||
<ViewTransition>{children}</ViewTransition>
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
<body className="bg-background font-sans text-foreground antialiased">
|
||||
<Providers>
|
||||
<Header />
|
||||
<div className="mx-auto mt-4 w-full max-w-4xl px-5">
|
||||
<main>
|
||||
<ViewTransition>{children}</ViewTransition>
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
<Toaster position="bottom-center" hotkey={[]} />
|
||||
<Analytics />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
<Toaster position="bottom-center" hotkey={[]} />
|
||||
<Analytics />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
export default RootLayout;
|
||||
|
||||
+51
-47
@@ -1,9 +1,9 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import type { VideoObject } from "schema-dts";
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { Video } from "@/components/video";
|
||||
import { env } from "@/lib/env";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import type { VideoObject } from "schema-dts";
|
||||
|
||||
import thumbnail from "./thumbnail.png";
|
||||
|
||||
@@ -21,52 +21,56 @@ export const metadata = createMetadata({
|
||||
},
|
||||
});
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<JsonLd<VideoObject>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: metadata.title as string,
|
||||
description: metadata.description as string,
|
||||
contentUrl: "https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo-uoCXHS9gViyRnQhr8CEGXFvj4VGh5Y.webm",
|
||||
thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`,
|
||||
embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/leo`,
|
||||
uploadDate: "2007-05-10T00:00:00Z",
|
||||
duration: "PT1M48S",
|
||||
}}
|
||||
/>
|
||||
|
||||
<PageTitle canonical="/leo">TheLab.mov</PageTitle>
|
||||
|
||||
<Video
|
||||
src={[
|
||||
const Page = () => (
|
||||
<>
|
||||
<JsonLd<VideoObject>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: metadata.title as string,
|
||||
description: metadata.description as string,
|
||||
contentUrl:
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo-uoCXHS9gViyRnQhr8CEGXFvj4VGh5Y.webm",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo-Blp1bsf872vuY05LuSw7fjZBHURWT1.mp4",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo.en-TsoyI7XMA10Uaj8EFZV1bQ65At35gz.vtt",
|
||||
]}
|
||||
poster={thumbnail.src}
|
||||
/>
|
||||
thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`,
|
||||
embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/leo`,
|
||||
uploadDate: "2007-05-10T00:00:00Z",
|
||||
duration: "PT1M48S",
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground mx-4 mt-5 mb-0 text-center text-sm leading-relaxed">
|
||||
Video is property of{" "}
|
||||
<a
|
||||
href="https://web.archive.org/web/20070511004304/www.g4techtv.ca"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold"
|
||||
>
|
||||
G4techTV Canada
|
||||
</a>{" "}
|
||||
&{" "}
|
||||
<a href="https://leo.fm/" target="_blank" rel="noopener noreferrer" className="font-bold">
|
||||
Leo Laporte
|
||||
</a>
|
||||
. © 2007 G4 Media, Inc.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
<PageTitle canonical="/leo">TheLab.mov</PageTitle>
|
||||
|
||||
<Video
|
||||
src={[
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo-uoCXHS9gViyRnQhr8CEGXFvj4VGh5Y.webm",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo-Blp1bsf872vuY05LuSw7fjZBHURWT1.mp4",
|
||||
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo.en-TsoyI7XMA10Uaj8EFZV1bQ65At35gz.vtt",
|
||||
]}
|
||||
poster={thumbnail.src}
|
||||
/>
|
||||
|
||||
<p className="mx-4 mt-5 mb-0 text-center text-muted-foreground text-sm leading-relaxed">
|
||||
Video is property of{" "}
|
||||
<a
|
||||
href="https://web.archive.org/web/20070511004304/www.g4techtv.ca"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold"
|
||||
>
|
||||
G4techTV Canada
|
||||
</a>{" "}
|
||||
&{" "}
|
||||
<a
|
||||
href="https://leo.fm/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold"
|
||||
>
|
||||
Leo Laporte
|
||||
</a>
|
||||
. © 2007 G4 Media, Inc.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { env } from "@/lib/env";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import type { MetadataRoute } from "next";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const manifest = (): MetadataRoute.Manifest => {
|
||||
return {
|
||||
|
||||
+19
-19
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Video } from "@/components/video";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found",
|
||||
@@ -12,24 +12,24 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<Video
|
||||
src="https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/not-found-SAtLyNyc7gVhveYxr6o1ITd9CSXo5X.mp4"
|
||||
autoPlay
|
||||
className="mt-6 aspect-[16/11] max-w-[480px] rounded-lg"
|
||||
/>
|
||||
const Page = () => (
|
||||
<>
|
||||
<Video
|
||||
src="https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/not-found-SAtLyNyc7gVhveYxr6o1ITd9CSXo5X.mp4"
|
||||
autoPlay
|
||||
className="mt-6 aspect-[16/11] max-w-[480px] rounded-lg"
|
||||
/>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<h1 className="my-2 text-2xl font-semibold md:text-3xl">Page Not Found</h1>
|
||||
<div className="mt-6 text-center">
|
||||
<h1 className="my-2 font-semibold text-2xl md:text-3xl">
|
||||
Page Not Found
|
||||
</h1>
|
||||
|
||||
<Button className="mt-4 mb-0 text-[15px] leading-none" size="lg" asChild>
|
||||
<Link href="/">Go home?</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
<Button className="mt-4 mb-0 text-[15px] leading-none" size="lg" asChild>
|
||||
<Link href="/">Go home?</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { env } from "@/lib/env";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { ImageResponse } from "next/og";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getSlugs, getFrontMatter, POSTS_DIR } from "@/lib/posts";
|
||||
import { ImageResponse } from "next/og";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import { env } from "@/lib/env";
|
||||
import { loadGoogleFont } from "@/lib/og-utils";
|
||||
import { getFrontMatter, getSlugs, POSTS_DIR } from "@/lib/posts";
|
||||
|
||||
export const contentType = "image/png";
|
||||
export const size = {
|
||||
@@ -31,7 +31,9 @@ const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
console.error(`[/notes/[slug]/opengraph-image] couldn't find an image file located at "${imagePath}"`);
|
||||
console.error(
|
||||
`[/notes/[slug]/opengraph-image] couldn't find an image file located at "${imagePath}"`,
|
||||
);
|
||||
|
||||
// return a 1x1 transparent gif if the image doesn't exist instead of crashing
|
||||
return NO_IMAGE;
|
||||
@@ -40,27 +42,40 @@ const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
|
||||
// return the raw image data as a buffer
|
||||
return Uint8Array.from(await fs.promises.readFile(imagePath)).buffer;
|
||||
} catch (error) {
|
||||
console.error(`[/notes/[slug]/opengraph-image] found "${imagePath}" but couldn't read it:`, error);
|
||||
console.error(
|
||||
`[/notes/[slug]/opengraph-image] found "${imagePath}" but couldn't read it:`,
|
||||
error,
|
||||
);
|
||||
|
||||
// fail silently and return a 1x1 transparent gif instead of crashing
|
||||
return NO_IMAGE;
|
||||
}
|
||||
};
|
||||
|
||||
const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
const OpenGraphImage = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) => {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// get the post's title and image filename from its frontmatter
|
||||
const frontmatter = await getFrontMatter(slug);
|
||||
if (!frontmatter) notFound();
|
||||
|
||||
// IMPORTANT: include these exact paths in next.config.ts under "outputFileTracingIncludes"
|
||||
const [postImg, avatarImg] = await Promise.all([
|
||||
frontmatter!.image ? getLocalImage(`${POSTS_DIR}/${slug}/${frontmatter!.image}`) : null,
|
||||
frontmatter.image
|
||||
? getLocalImage(`${POSTS_DIR}/${slug}/${frontmatter.image}`)
|
||||
: null,
|
||||
getLocalImage("app/avatar.jpg"),
|
||||
]);
|
||||
|
||||
const [fontRegular, fontSemibold] = await Promise.all([loadGoogleFont("Inter", 400), loadGoogleFont("Inter", 600)]);
|
||||
const [fontRegular, fontSemibold] = await Promise.all([
|
||||
loadGoogleFont("Inter", 400),
|
||||
loadGoogleFont("Inter", 600),
|
||||
]);
|
||||
|
||||
// template is HEAVILY inspired by https://og-new.clerkstage.dev/
|
||||
return new ImageResponse(
|
||||
@@ -69,7 +84,8 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
|
||||
...size,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "linear-gradient(to top right, rgb(134, 239, 172), rgb(59, 130, 246), rgb(147, 51, 234))",
|
||||
background:
|
||||
"linear-gradient(to top right, rgb(134, 239, 172), rgb(59, 130, 246), rgb(147, 51, 234))",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -123,6 +139,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
|
||||
}}
|
||||
>
|
||||
{avatarImg && (
|
||||
// biome-ignore lint/performance/noImgElement: Satori/ImageResponse requires raw <img> tags
|
||||
<img
|
||||
// @ts-expect-error
|
||||
src={avatarImg}
|
||||
@@ -158,7 +175,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
|
||||
lineHeight: "1.2",
|
||||
}}
|
||||
>
|
||||
{frontmatter!.title}
|
||||
{frontmatter.title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -196,11 +213,14 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
|
||||
lineHeight: "1.2",
|
||||
}}
|
||||
>
|
||||
{new Date(frontmatter!.date).toLocaleDateString(env.NEXT_PUBLIC_SITE_LOCALE, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
{new Date(frontmatter.date).toLocaleDateString(
|
||||
env.NEXT_PUBLIC_SITE_LOCALE,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,6 +232,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
|
||||
flexGrow: 0,
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: Satori/ImageResponse requires raw <img> tags */}
|
||||
<img
|
||||
// @ts-expect-error
|
||||
src={postImg}
|
||||
@@ -242,10 +263,13 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
|
||||
weight: 600,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[/notes/[slug]/opengraph-image] error generating open graph image:", error);
|
||||
console.error(
|
||||
"[/notes/[slug]/opengraph-image] error generating open graph image:",
|
||||
error,
|
||||
);
|
||||
notFound();
|
||||
}
|
||||
};
|
||||
|
||||
+90
-51
@@ -1,19 +1,26 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
EyeIcon,
|
||||
MessagesSquareIcon,
|
||||
SquarePenIcon,
|
||||
TagIcon,
|
||||
} from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import { CalendarDaysIcon, TagIcon, SquarePenIcon, EyeIcon, MessagesSquareIcon } from "lucide-react";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import type { BlogPosting } from "schema-dts";
|
||||
import { CommentCount } from "@/components/comment-count";
|
||||
import { Comments } from "@/components/comments/comments";
|
||||
import { CommentsSkeleton } from "@/components/comments/comments-skeleton";
|
||||
import { getSlugs, getFrontMatter, POSTS_DIR } from "@/lib/posts";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import authorConfig from "@/lib/config/author";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import { env } from "@/lib/env";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { getFrontMatter, getSlugs, POSTS_DIR } from "@/lib/posts";
|
||||
import { size as ogImageSize } from "./opengraph-image";
|
||||
import type { Metadata } from "next";
|
||||
import type { BlogPosting } from "schema-dts";
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
const slugs = await getSlugs();
|
||||
@@ -24,20 +31,24 @@ export const generateStaticParams = async () => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> => {
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> => {
|
||||
const { slug } = await params;
|
||||
const frontmatter = await getFrontMatter(slug);
|
||||
|
||||
return createMetadata({
|
||||
title: frontmatter!.title,
|
||||
description: frontmatter!.description,
|
||||
title: frontmatter?.title,
|
||||
description: frontmatter?.description,
|
||||
canonical: `/${POSTS_DIR}/${slug}`,
|
||||
openGraph: {
|
||||
type: "article",
|
||||
authors: [authorConfig.name],
|
||||
tags: frontmatter!.tags,
|
||||
publishedTime: frontmatter!.date,
|
||||
modifiedTime: frontmatter!.date,
|
||||
tags: frontmatter?.tags,
|
||||
publishedTime: frontmatter?.date,
|
||||
modifiedTime: frontmatter?.date,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
@@ -48,7 +59,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ slug: str
|
||||
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
const { slug } = await params;
|
||||
const frontmatter = await getFrontMatter(slug);
|
||||
const d = new Date(frontmatter!.date);
|
||||
if (!frontmatter) notFound();
|
||||
const d = new Date(frontmatter.date);
|
||||
|
||||
const formattedDates = {
|
||||
dateISO: d.toISOString(),
|
||||
@@ -60,10 +72,16 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}),
|
||||
dateDisplay: d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
|
||||
dateDisplay: d.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
};
|
||||
|
||||
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`);
|
||||
const { default: MDXContent } = await import(
|
||||
`../../../${POSTS_DIR}/${slug}/index.mdx`
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,18 +89,18 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: frontmatter!.title,
|
||||
description: frontmatter!.description,
|
||||
url: frontmatter!.permalink,
|
||||
headline: frontmatter?.title,
|
||||
description: frontmatter?.description,
|
||||
url: frontmatter?.permalink,
|
||||
image: {
|
||||
"@type": "ImageObject",
|
||||
contentUrl: `${env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${frontmatter!.slug}/opengraph-image`,
|
||||
contentUrl: `${env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${frontmatter?.slug}/opengraph-image`,
|
||||
width: `${ogImageSize.width}`,
|
||||
height: `${ogImageSize.height}`,
|
||||
},
|
||||
keywords: frontmatter!.tags?.join(", "),
|
||||
datePublished: frontmatter!.date,
|
||||
dateModified: frontmatter!.date,
|
||||
keywords: frontmatter?.tags?.join(", "),
|
||||
datePublished: frontmatter?.date,
|
||||
dateModified: frontmatter?.date,
|
||||
inLanguage: env.NEXT_PUBLIC_SITE_LOCALE,
|
||||
license: `https://spdx.org/licenses/${siteConfig.license}.html`,
|
||||
author: {
|
||||
@@ -92,26 +110,34 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="text-foreground/70 flex flex-wrap justify-items-start gap-4 text-[13px] tracking-wide">
|
||||
<div className="flex flex-wrap justify-items-start gap-4 text-[13px] text-foreground/70 tracking-wide">
|
||||
<Link
|
||||
href={`/${POSTS_DIR}/${frontmatter!.slug}`}
|
||||
className={"text-foreground/70 flex flex-nowrap items-center gap-1.5 whitespace-nowrap hover:no-underline"}
|
||||
href={`/${POSTS_DIR}/${frontmatter?.slug}`}
|
||||
className={
|
||||
"flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-foreground/70 hover:no-underline"
|
||||
}
|
||||
>
|
||||
<CalendarDaysIcon className="inline size-3 shrink-0" aria-hidden="true" />
|
||||
<time dateTime={formattedDates.dateISO} title={formattedDates.dateTitle} suppressHydrationWarning>
|
||||
<CalendarDaysIcon
|
||||
className="inline size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<time
|
||||
dateTime={formattedDates.dateISO}
|
||||
title={formattedDates.dateTitle}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{formattedDates.dateDisplay}
|
||||
</time>
|
||||
</Link>
|
||||
|
||||
{frontmatter!.tags && (
|
||||
{frontmatter?.tags && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TagIcon className="inline size-3 shrink-0" aria-hidden="true" />
|
||||
{frontmatter!.tags.map((tag) => (
|
||||
{frontmatter?.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
title={tag}
|
||||
className="before:text-foreground/40 mx-px lowercase before:pr-0.5 before:content-['\0023'] first-of-type:ml-0 last-of-type:mr-0"
|
||||
aria-label={`Tagged with ${tag}`}
|
||||
className="mx-px lowercase before:pr-0.5 before:text-foreground/40 before:content-['\0023'] first-of-type:ml-0 last-of-type:mr-0"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -120,35 +146,46 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}/blob/main/${POSTS_DIR}/${frontmatter!.slug}/index.mdx`}
|
||||
title={`Edit "${frontmatter!.title}" on GitHub`}
|
||||
className={"text-foreground/70 flex flex-nowrap items-center gap-1.5 whitespace-nowrap hover:no-underline"}
|
||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}/blob/main/${POSTS_DIR}/${frontmatter?.slug}/index.mdx`}
|
||||
title={`Edit "${frontmatter?.title}" on GitHub`}
|
||||
className={
|
||||
"flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-foreground/70 hover:no-underline"
|
||||
}
|
||||
>
|
||||
<SquarePenIcon className="inline size-3 shrink-0" aria-hidden="true" />
|
||||
<SquarePenIcon
|
||||
className="inline size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Improve This Post</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/${POSTS_DIR}/${frontmatter!.slug}#comments`}
|
||||
className="text-foreground/70 flex flex-nowrap items-center gap-1.5 whitespace-nowrap hover:no-underline"
|
||||
href={`/${POSTS_DIR}/${frontmatter?.slug}#comments`}
|
||||
className="flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-foreground/70 hover:no-underline"
|
||||
>
|
||||
<MessagesSquareIcon className="inline size-3 shrink-0" aria-hidden="true" />
|
||||
<CommentCount slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
|
||||
<MessagesSquareIcon
|
||||
className="inline size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommentCount slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-14 flex-nowrap items-center gap-1.5 whitespace-nowrap">
|
||||
<EyeIcon className="inline size-3 shrink-0" aria-hidden="true" />
|
||||
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
|
||||
<ViewCounter slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="my-5 text-3xl font-medium tracking-tight"
|
||||
style={{ viewTransitionName: `note-title-${frontmatter!.slug}` }}
|
||||
className="my-5 font-medium text-3xl tracking-tight"
|
||||
style={{ viewTransitionName: `note-title-${frontmatter?.slug}` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${POSTS_DIR}/${frontmatter!.slug}`}
|
||||
dangerouslySetInnerHTML={{ __html: frontmatter!.htmlTitle || frontmatter!.title }}
|
||||
href={`/${POSTS_DIR}/${frontmatter?.slug}`}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: htmlTitle is sanitized by rehypeSanitize in lib/posts.ts
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: frontmatter.htmlTitle || frontmatter.title,
|
||||
}}
|
||||
className="text-foreground hover:no-underline"
|
||||
/>
|
||||
</h1>
|
||||
@@ -157,13 +194,15 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
|
||||
<section id="comments" className="isolate my-8 w-full border-t-2 pt-8">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
{frontmatter!.noComments ? (
|
||||
<div className="bg-muted/40 flex justify-center rounded-lg px-6 py-12">
|
||||
<p className="text-center text-lg font-medium">Comments are closed.</p>
|
||||
{frontmatter?.noComments ? (
|
||||
<div className="flex justify-center rounded-lg bg-muted/40 px-6 py-12">
|
||||
<p className="text-center font-medium text-lg">
|
||||
Comments are closed.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<CommentsSkeleton />}>
|
||||
<Comments slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
|
||||
<Comments slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+50
-37
@@ -1,9 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { PostStats, PostStatsProvider } from "@/components/post-stats";
|
||||
import { getFrontMatter, POSTS_DIR, type FrontMatter } from "@/lib/posts";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import authorConfig from "@/lib/config/author";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { type FrontMatter, getFrontMatter, POSTS_DIR } from "@/lib/posts";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "Notes",
|
||||
@@ -28,7 +28,10 @@ const PostsList = async () => {
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}),
|
||||
dateDisplay: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||
dateDisplay: d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -41,41 +44,53 @@ const PostsList = async () => {
|
||||
})[];
|
||||
} = {};
|
||||
|
||||
formattedPosts.forEach((post) => {
|
||||
(postsByYear[post.year] || (postsByYear[post.year] = [])).push(post);
|
||||
});
|
||||
for (const post of formattedPosts) {
|
||||
if (!postsByYear[post.year]) {
|
||||
postsByYear[post.year] = [];
|
||||
}
|
||||
postsByYear[post.year].push(post);
|
||||
}
|
||||
|
||||
const sections: React.ReactNode[] = [];
|
||||
|
||||
Object.entries(postsByYear).forEach(([year, posts]) => {
|
||||
sections.push(
|
||||
<section className="my-8 first-of-type:mt-0 last-of-type:mb-0" key={year}>
|
||||
<h2 id={year} className="mt-0 mb-4 text-2xl font-semibold tracking-tight">
|
||||
<h2
|
||||
id={year}
|
||||
className="mt-0 mb-4 font-semibold text-2xl tracking-tight"
|
||||
>
|
||||
{year}
|
||||
</h2>
|
||||
<ul className="space-y-4">
|
||||
{posts.map(({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle }) => (
|
||||
<li className="flex text-base leading-relaxed" key={slug}>
|
||||
<span className="text-muted-foreground w-18 shrink-0 md:w-22">
|
||||
<time dateTime={dateISO} title={dateTitle} suppressHydrationWarning>
|
||||
{dateDisplay}
|
||||
</time>
|
||||
</span>
|
||||
<div className="space-x-2">
|
||||
{/* htmlTitle is sanitized by rehypeSanitize in lib/posts.ts with strict allowlist: only code, em, strong tags */}
|
||||
<Link
|
||||
href={`/${POSTS_DIR}/${slug}`}
|
||||
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
|
||||
className="mr-2.5 underline-offset-4 hover:underline"
|
||||
style={{ viewTransitionName: `note-title-${slug}` }}
|
||||
/>
|
||||
{posts.map(
|
||||
({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle }) => (
|
||||
<li className="flex text-base leading-relaxed" key={slug}>
|
||||
<span className="w-18 shrink-0 text-muted-foreground md:w-22">
|
||||
<time
|
||||
dateTime={dateISO}
|
||||
title={dateTitle}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{dateDisplay}
|
||||
</time>
|
||||
</span>
|
||||
<div className="space-x-2">
|
||||
<Link
|
||||
href={`/${POSTS_DIR}/${slug}`}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: htmlTitle is sanitized by rehypeSanitize in lib/posts.ts
|
||||
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
|
||||
className="mr-2.5 underline-offset-4 hover:underline"
|
||||
style={{ viewTransitionName: `note-title-${slug}` }}
|
||||
/>
|
||||
|
||||
<PostStats slug={`${POSTS_DIR}/${slug}`} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<PostStats slug={`${POSTS_DIR}/${slug}`} />
|
||||
</div>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
</section>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,15 +98,13 @@ const PostsList = async () => {
|
||||
return <>{sections.reverse()}</>;
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
return (
|
||||
<>
|
||||
<PageTitle canonical="/notes">Notes</PageTitle>
|
||||
<PostStatsProvider>
|
||||
<PostsList />
|
||||
</PostStatsProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const Page = async () => (
|
||||
<>
|
||||
<PageTitle canonical="/notes">Notes</PageTitle>
|
||||
<PostStatsProvider>
|
||||
<PostsList />
|
||||
</PostStatsProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
+145
-128
@@ -1,140 +1,157 @@
|
||||
import Link from "next/link";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-neutral dark:prose-invert prose-sm max-w-none",
|
||||
"prose-headings:font-semibold prose-headings:text-primary prose-headings:tracking-tight prose-headings:mt-0 prose-headings:mb-3",
|
||||
"prose-p:text-foreground/90 prose-p:my-3 prose-p:leading-[1.75] md:prose-p:leading-relaxed prose-strong:text-primary prose-li:text-foreground/80",
|
||||
"prose-a:text-primary prose-a:font-medium prose-a:underline prose-a:underline-offset-4",
|
||||
"prose-code:bg-muted prose-code:text-foreground prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-[0.9em] prose-code:before:content-none prose-code:after:content-none",
|
||||
"[&_table]:!border-[color:var(--border)] [&_td]:!border-[color:var(--border)] [&_th]:!border-[color:var(--border)]"
|
||||
)}
|
||||
>
|
||||
<h1 className="text-2xl font-medium">
|
||||
Hi there! I’m Jake.{" "}
|
||||
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-2xl">👋</span>
|
||||
</h1>
|
||||
const Page = () => (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-neutral dark:prose-invert prose-sm max-w-none",
|
||||
"prose-headings:mt-0 prose-headings:mb-3 prose-headings:font-semibold prose-headings:text-primary prose-headings:tracking-tight",
|
||||
"prose-p:my-3 prose-li:text-foreground/80 prose-p:text-foreground/90 prose-strong:text-primary prose-p:leading-[1.75] md:prose-p:leading-relaxed",
|
||||
"prose-a:font-medium prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
|
||||
"prose-code:rounded-sm prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none",
|
||||
"[&_table]:!border-[color:var(--border)] [&_td]:!border-[color:var(--border)] [&_th]:!border-[color:var(--border)]",
|
||||
)}
|
||||
>
|
||||
<h1 className="font-medium text-2xl">
|
||||
Hi there! I’m Jake.{" "}
|
||||
<span className="ml-0.5 inline-block origin-[65%_80%] text-2xl motion-safe:animate-wave">
|
||||
👋
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<h2 className="font-normal">
|
||||
I’m a frontend web developer based in the{" "}
|
||||
<Link
|
||||
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&fs=1&showinfo=1&rel=0&iv_load_policy=3"
|
||||
title='"Boston Accent Trailer - Late Night with Seth Meyers" on YouTube'
|
||||
>
|
||||
Boston
|
||||
</Link>{" "}
|
||||
area.
|
||||
</h2>
|
||||
<h2 className="font-normal">
|
||||
I’m a frontend web developer based in the{" "}
|
||||
<Link
|
||||
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&fs=1&showinfo=1&rel=0&iv_load_policy=3"
|
||||
title='"Boston Accent Trailer - Late Night with Seth Meyers" on YouTube'
|
||||
>
|
||||
Boston
|
||||
</Link>{" "}
|
||||
area.
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
I specialize in using TypeScript, React, and Next.js to make lightweight frontends with dynamic and powerful
|
||||
backends.
|
||||
</p>
|
||||
<p>
|
||||
I specialize in using TypeScript, React, and Next.js to make lightweight
|
||||
frontends with dynamic and powerful backends.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Whenever possible, I also apply my experience in{" "}
|
||||
<p>
|
||||
Whenever possible, I also apply my experience in{" "}
|
||||
<a
|
||||
href="https://bugcrowd.com/jakejarvis"
|
||||
title="Jake Jarvis on Bugcrowd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
information security
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="https://github.com/jakejarvis?tab=repositories&q=github-actions&type=&language=&sort=stargazers"
|
||||
title='My repositories tagged with "github-actions" on GitHub'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
devops
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I fell in love with{" "}
|
||||
<Link
|
||||
href="/previously"
|
||||
title="My Terrible, Horrible, No Good, Very Bad First Websites"
|
||||
>
|
||||
frontend web design
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/notes/my-first-code"
|
||||
title="Jake's Bulletin Board, circa 2003"
|
||||
>
|
||||
backend coding
|
||||
</Link>{" "}
|
||||
when my only source of income was{" "}
|
||||
<Link
|
||||
href="/birthday"
|
||||
title="🎉 Cranky Birthday Boy on VHS Tape 📼"
|
||||
style={{
|
||||
cursor: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='30' style='font-size:24px'><text y='50%' transform='rotate(-70 0 0) translate(-20, 6)'>🪄</text></svg>") 5 5, auto`,
|
||||
}}
|
||||
className="font-normal no-underline"
|
||||
>
|
||||
the Tooth Fairy
|
||||
</Link>
|
||||
.{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(I’ve improved a bit since then, I think?)
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I’m currently building{" "}
|
||||
<a
|
||||
href="https://domainstack.io"
|
||||
title="Domainstack: Domain intelligence made easy"
|
||||
className="font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Domainstack
|
||||
</a>
|
||||
, a beautiful all-in-one domain name intelligence tool, and{" "}
|
||||
<a
|
||||
href="https://snoozle.ai"
|
||||
title="Snoozle: AI-powered bedtime stories for children"
|
||||
className="font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Snoozle
|
||||
</a>
|
||||
, an AI-powered bedtime story generator.
|
||||
</p>
|
||||
|
||||
<p className="mt-2 mb-0 text-sm leading-normal">
|
||||
You can find my work on{" "}
|
||||
<a
|
||||
href="https://github.com/jakejarvis"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer me"
|
||||
>
|
||||
GitHub
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="https://www.linkedin.com/in/jakejarvis/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer me"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
. I’m always available to connect over{" "}
|
||||
<Link href="/contact" title="Send an email">
|
||||
email
|
||||
</Link>{" "}
|
||||
<sup className="">
|
||||
<a
|
||||
href="https://bugcrowd.com/jakejarvis"
|
||||
title="Jake Jarvis on Bugcrowd"
|
||||
href="https://keyoxide.org/hkp/3bc6e5776bf379d36f6714802b0c9cf251e69a39"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel="noopener pgpkey"
|
||||
title="Download my PGP key"
|
||||
className="not-prose space-x-1 text-nowrap px-0.5 text-muted-foreground no-underline hover:text-primary hover:no-underline"
|
||||
>
|
||||
information security
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="https://github.com/jakejarvis?tab=repositories&q=github-actions&type=&language=&sort=stargazers"
|
||||
title='My repositories tagged with "github-actions" on GitHub'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
devops
|
||||
<LockIcon className="inline size-2.5" aria-hidden="true" />
|
||||
<code className="text-wrap text-[9px] leading-none tracking-wider [word-spacing:-3px]">
|
||||
2B0C 9CF2 51E6 9A39
|
||||
</code>
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I fell in love with{" "}
|
||||
<Link href="/previously" title="My Terrible, Horrible, No Good, Very Bad First Websites">
|
||||
frontend web design
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/notes/my-first-code" title="Jake's Bulletin Board, circa 2003">
|
||||
backend coding
|
||||
</Link>{" "}
|
||||
when my only source of income was{" "}
|
||||
<Link
|
||||
href="/birthday"
|
||||
title="🎉 Cranky Birthday Boy on VHS Tape 📼"
|
||||
style={{
|
||||
cursor: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='30' style='font-size:24px'><text y='50%' transform='rotate(-70 0 0) translate(-20, 6)'>🪄</text></svg>") 5 5, auto`,
|
||||
}}
|
||||
className="font-normal no-underline"
|
||||
>
|
||||
the Tooth Fairy
|
||||
</Link>
|
||||
. <span className="text-muted-foreground">(I’ve improved a bit since then, I think?)</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I’m currently building{" "}
|
||||
<a
|
||||
href="https://domainstack.io"
|
||||
title="Domainstack: Domain intelligence made easy"
|
||||
className="font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Domainstack
|
||||
</a>
|
||||
, a beautiful all-in-one domain name intelligence tool, and{" "}
|
||||
<a
|
||||
href="https://snoozle.ai"
|
||||
title="Snoozle: AI-powered bedtime stories for children"
|
||||
className="font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Snoozle
|
||||
</a>
|
||||
, an AI-powered bedtime story generator.
|
||||
</p>
|
||||
|
||||
<p className="mt-2 mb-0 text-sm leading-normal">
|
||||
You can find my work on{" "}
|
||||
<a href="https://github.com/jakejarvis" target="_blank" rel="noopener noreferrer me">
|
||||
GitHub
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="https://www.linkedin.com/in/jakejarvis/" target="_blank" rel="noopener noreferrer me">
|
||||
LinkedIn
|
||||
</a>
|
||||
. I’m always available to connect over{" "}
|
||||
<Link href="/contact" title="Send an email">
|
||||
email
|
||||
</Link>{" "}
|
||||
<sup className="">
|
||||
<a
|
||||
href="https://keyoxide.org/hkp/3bc6e5776bf379d36f6714802b0c9cf251e69a39"
|
||||
target="_blank"
|
||||
rel="noopener pgpkey"
|
||||
title="Download my PGP key"
|
||||
className="not-prose text-muted-foreground hover:text-primary space-x-1 px-0.5 text-nowrap no-underline hover:no-underline"
|
||||
>
|
||||
<LockIcon className="inline size-2.5" aria-hidden="true" />
|
||||
<code className="text-[9px] leading-none tracking-wider text-wrap [word-spacing:-3px]">
|
||||
2B0C 9CF2 51E6 9A39
|
||||
</code>
|
||||
</a>
|
||||
</sup>{" "}
|
||||
as well.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</sup>{" "}
|
||||
as well.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
+18
-15
@@ -1,10 +1,10 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
import { cacheLife } from "next/cache";
|
||||
import * as cheerio from "cheerio";
|
||||
import { graphql } from "@octokit/graphql";
|
||||
import type { Repository, User } from "@octokit/graphql-schema";
|
||||
import * as cheerio from "cheerio";
|
||||
import { cacheLife } from "next/cache";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export const getContributions = async (): Promise<
|
||||
Array<{
|
||||
@@ -18,12 +18,15 @@ export const getContributions = async (): Promise<
|
||||
|
||||
// thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts
|
||||
try {
|
||||
const response = await fetch(`https://github.com/users/${env.NEXT_PUBLIC_GITHUB_USERNAME}/contributions`, {
|
||||
headers: {
|
||||
referer: `https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`,
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
const response = await fetch(
|
||||
`https://github.com/users/${env.NEXT_PUBLIC_GITHUB_USERNAME}/contributions`,
|
||||
{
|
||||
headers: {
|
||||
referer: `https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`,
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const $ = cheerio.load(await response.text());
|
||||
|
||||
@@ -38,15 +41,15 @@ export const getContributions = async (): Promise<
|
||||
|
||||
const dayTooltips = $(".js-calendar-graph tool-tip")
|
||||
.toArray()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: cheerio DOM element map
|
||||
.reduce<Record<string, any>>((map, elem) => {
|
||||
map[elem.attribs["for"]] = elem;
|
||||
map[elem.attribs.for] = elem;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return days.map((day) => {
|
||||
const attr = {
|
||||
id: day.attribs["id"],
|
||||
id: day.attribs.id,
|
||||
date: day.attribs["data-date"],
|
||||
level: day.attribs["data-level"],
|
||||
};
|
||||
@@ -57,12 +60,12 @@ export const getContributions = async (): Promise<
|
||||
if (text) {
|
||||
const countMatch = text.data.trim().match(/^\d+/);
|
||||
if (countMatch) {
|
||||
count = parseInt(countMatch[0]);
|
||||
count = parseInt(countMatch[0], 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const level = parseInt(attr.level);
|
||||
const level = parseInt(attr.level, 10);
|
||||
|
||||
return {
|
||||
date: attr.date,
|
||||
@@ -120,10 +123,10 @@ export const getRepos = async (): Promise<Repository[] | undefined> => {
|
||||
accept: "application/vnd.github.v3+json",
|
||||
authorization: `token ${env.GITHUB_TOKEN}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return user.repositories.edges?.map((edge) => edge!.node as Repository);
|
||||
return user.repositories.edges?.map((edge) => edge?.node as Repository);
|
||||
} catch (error) {
|
||||
console.error("[server/github] Failed to fetch repositories:", error);
|
||||
return [];
|
||||
|
||||
+74
-41
@@ -1,16 +1,16 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { GitForkIcon, StarIcon } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { RelativeTime } from "@/components/relative-time";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { ActivityCalendar } from "@/components/activity-calendar";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { getContributions, getRepos } from "./github";
|
||||
import { PageTitle } from "@/components/layout/page-title";
|
||||
import { RelativeTime } from "@/components/relative-time";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { env } from "@/lib/env";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getContributions, getRepos } from "./github";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "Projects",
|
||||
@@ -22,18 +22,23 @@ const Page = async () => {
|
||||
// don't fail the entire site build if the required config for this page is missing, just return a 404 since this page
|
||||
// would be mostly blank anyways.
|
||||
if (!env.GITHUB_TOKEN) {
|
||||
console.error("[/projects] I can't fetch anything from GitHub without 'GITHUB_TOKEN' set!");
|
||||
console.error(
|
||||
"[/projects] I can't fetch anything from GitHub without 'GITHUB_TOKEN' set!",
|
||||
);
|
||||
notFound();
|
||||
}
|
||||
|
||||
// fetch the repos and contributions in parallel
|
||||
const [contributions, repos] = await Promise.all([getContributions(), getRepos()]);
|
||||
const [contributions, repos] = await Promise.all([
|
||||
getContributions(),
|
||||
getRepos(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle canonical="/projects">Projects</PageTitle>
|
||||
|
||||
<h2 className="my-3.5 text-xl font-medium">
|
||||
<h2 className="my-3.5 font-medium text-xl">
|
||||
<a
|
||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`}
|
||||
target="_blank"
|
||||
@@ -50,11 +55,13 @@ const Page = async () => {
|
||||
<ActivityCalendar data={contributions} noun="contribution" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground my-4 text-center">Unable to load contribution data at this time.</p>
|
||||
<p className="my-4 text-center text-muted-foreground">
|
||||
Unable to load contribution data at this time.
|
||||
</p>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
<h2 className="my-3.5 text-xl font-medium">
|
||||
<h2 className="my-3.5 font-medium text-xl">
|
||||
<a
|
||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&sort=stargazers`}
|
||||
target="_blank"
|
||||
@@ -68,61 +75,85 @@ const Page = async () => {
|
||||
{repos && repos.length > 0 ? (
|
||||
<div className="row-auto grid w-full grid-cols-none gap-4 md:grid-cols-2">
|
||||
{repos.map((repo) => (
|
||||
<div key={repo!.name} className="border-ring/30 h-fit space-y-1.5 rounded-2xl border-1 px-4 py-3 shadow-xs">
|
||||
<div
|
||||
key={repo?.name}
|
||||
className="h-fit space-y-1.5 rounded-2xl border-1 border-ring/30 px-4 py-3 shadow-xs"
|
||||
>
|
||||
<a
|
||||
href={repo!.url}
|
||||
href={repo?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-base leading-relaxed font-semibold text-[#0969da] hover:underline dark:text-[#4493f8]"
|
||||
className="inline-block font-semibold text-[#0969da] text-base leading-relaxed hover:underline dark:text-[#4493f8]"
|
||||
>
|
||||
{repo!.name}
|
||||
{repo?.name}
|
||||
</a>
|
||||
|
||||
{repo!.description && <p className="text-foreground/85 text-sm leading-relaxed">{repo!.description}</p>}
|
||||
{repo?.description && (
|
||||
<p className="text-foreground/85 text-sm leading-relaxed">
|
||||
{repo?.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 text-[0.825rem] leading-loose whitespace-nowrap">
|
||||
{repo!.primaryLanguage && (
|
||||
<div className="text-muted-foreground inline-flex flex-nowrap items-center gap-2">
|
||||
{repo!.primaryLanguage.color && (
|
||||
<div className="flex flex-wrap gap-x-4 whitespace-nowrap text-[0.825rem] leading-loose">
|
||||
{repo?.primaryLanguage && (
|
||||
<div className="inline-flex flex-nowrap items-center gap-2 text-muted-foreground">
|
||||
{repo?.primaryLanguage.color && (
|
||||
<span
|
||||
className="inline-block size-4 rounded-full bg-[var(--language-color)]"
|
||||
style={{ ["--language-color" as string]: repo!.primaryLanguage.color }}
|
||||
style={{
|
||||
["--language-color" as string]:
|
||||
repo?.primaryLanguage.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{repo!.primaryLanguage.name}</span>
|
||||
<span>{repo?.primaryLanguage.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repo!.stargazerCount > 0 && (
|
||||
{repo?.stargazerCount > 0 && (
|
||||
<a
|
||||
href={`${repo!.url}/stargazers`}
|
||||
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)} ${repo!.stargazerCount === 1 ? "star" : "stars"}`}
|
||||
href={`${repo?.url}/stargazers`}
|
||||
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo?.stargazerCount)} ${repo?.stargazerCount === 1 ? "star" : "stars"}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline"
|
||||
className="inline-flex flex-nowrap items-center gap-2 text-muted-foreground hover:text-primary hover:no-underline"
|
||||
>
|
||||
<StarIcon className="inline-block size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)}</span>
|
||||
<StarIcon
|
||||
className="inline-block size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(
|
||||
repo?.stargazerCount,
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{repo!.forkCount > 0 && (
|
||||
{repo?.forkCount > 0 && (
|
||||
<a
|
||||
href={`${repo!.url}/network/members`}
|
||||
href={`${repo?.url}/network/members`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)} ${repo!.forkCount === 1 ? "fork" : "forks"}`}
|
||||
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline"
|
||||
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo?.forkCount)} ${repo?.forkCount === 1 ? "fork" : "forks"}`}
|
||||
className="inline-flex flex-nowrap items-center gap-2 text-muted-foreground hover:text-primary hover:no-underline"
|
||||
>
|
||||
<GitForkIcon className="inline-block size-4" aria-hidden="true" />
|
||||
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)}</span>
|
||||
<GitForkIcon
|
||||
className="inline-block size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(
|
||||
repo?.forkCount,
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground whitespace-nowrap">
|
||||
<div className="whitespace-nowrap text-muted-foreground">
|
||||
<Suspense fallback={null}>
|
||||
<span>
|
||||
Updated <RelativeTime date={repo!.pushedAt} />
|
||||
Updated <RelativeTime date={repo?.pushedAt} />
|
||||
</span>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -131,10 +162,12 @@ const Page = async () => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground my-4 text-center">Unable to load repository data at this time.</p>
|
||||
<p className="my-4 text-center text-muted-foreground">
|
||||
Unable to load repository data at this time.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-6 mb-0 text-center text-base font-medium">
|
||||
<p className="mt-6 mb-0 text-center font-medium text-base">
|
||||
<Button variant="secondary" asChild>
|
||||
<a
|
||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&type=source&sort=stargazers`}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { env } from "@/lib/env";
|
||||
import type { MetadataRoute } from "next";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const robots = (): MetadataRoute.Robots => ({
|
||||
rules: [
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { env } from "@/lib/env";
|
||||
import path from "path";
|
||||
import path from "node:path";
|
||||
import glob from "fast-glob";
|
||||
import { getFrontMatter } from "@/lib/posts";
|
||||
import type { MetadataRoute } from "next";
|
||||
import { env } from "@/lib/env";
|
||||
import { getFrontMatter } from "@/lib/posts";
|
||||
|
||||
// routes in /app (in other words, directories containing a page.tsx/mdx file) are automatically included; add a route
|
||||
// here to exclude it.
|
||||
|
||||
Reference in New Issue
Block a user