1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-09-18 13:25:32 -04:00

add github activity graph to /projects

This commit is contained in:
2025-04-18 18:48:08 -04:00
parent 98ea88dae9
commit ba62de355f
10 changed files with 540 additions and 160 deletions

View File

@@ -30,8 +30,6 @@ const ContactSchema = v.object({
v.nonEmpty("Just do the stinkin CAPTCHA, human! 🤖"),
// very rudimentary length check based on Cloudflare's docs
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
v.minLength("XXXX.DUMMY.TOKEN.XXXX".length),
// "A Turnstile token can have up to 2048 characters."
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
v.maxLength(2048),
v.readonly()
@@ -48,7 +46,7 @@ export type ContactState = {
export const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
// TODO: remove after debugging why automated spam entries are causing 500 errors
console.debug("[contact form] received payload:", payload);
console.debug("[/contact] received payload:", payload);
try {
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
@@ -80,7 +78,7 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
});
if (!turnstileResponse || !turnstileResponse.ok) {
throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`);
throw new Error(`[/contact] turnstile validation failed: ${turnstileResponse.status}`);
}
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
@@ -92,9 +90,9 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
};
}
if (!env.RESEND_FROM_EMAIL) {
if (env.RESEND_FROM_EMAIL === "onboarding@resend.dev") {
// https://resend.com/docs/api-reference/emails/send-email
console.warn("[contact form] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
console.warn("[/contact] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
}
// send email
@@ -109,7 +107,7 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
return { success: true, message: "Thanks! You should hear from me soon." };
} catch (error) {
console.error("[contact form] fatal error:", error);
console.error("[/contact] fatal error:", error);
return {
success: false,

View File

@@ -22,7 +22,7 @@ const HitCounter = async ({ slug }: { slug: string }) => {
</span>
);
} catch (error) {
console.error("[hit counter] fatal error:", error);
console.error("[/notes/[slug]/counter] fatal error:", error);
return <span title="Error getting views! :(">?</span>;
}

View File

@@ -34,7 +34,7 @@ const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
try {
if (!fs.existsSync(imagePath)) {
console.error(`[og-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;
@@ -43,7 +43,7 @@ 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(`[og-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;
@@ -256,7 +256,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
}
);
} catch (error) {
console.error("[og-image] error generating image:", error);
console.error("[/notes/[slug]/opengraph-image] error generating open graph image:", error);
notFound();
}
};

View File

@@ -0,0 +1,33 @@
.calendar {
--activity-0: #ebedf0;
--activity-1: #9be9a8;
--activity-2: #40c463;
--activity-3: #30a14e;
--activity-4: #216e39;
}
[data-theme="dark"] .calendar {
--activity-0: #252525;
--activity-1: #033a16;
--activity-2: #196c2e;
--activity-3: #2ea043;
--activity-4: #56d364;
}
.calendar :global(.react-activity-calendar) {
margin: 1em auto;
}
.calendar :global(.react-activity-calendar__count),
.calendar :global(.react-activity-calendar__legend-month) {
color: var(--colors-medium);
}
.calendar :global(.react-activity-calendar__legend-colors) {
color: var(--colors-medium-light);
}
.tooltip {
background-color: var(--colors-background-header);
color: var(--colors-text);
}

59
app/projects/calendar.tsx Normal file
View File

@@ -0,0 +1,59 @@
"use client";
import { cloneElement } from "react";
import { ActivityCalendar } from "react-activity-calendar";
import { Tooltip } from "react-tooltip";
import clsx from "clsx";
import { format } from "date-fns";
import type { ComponentPropsWithoutRef } from "react";
import type { Activity } from "react-activity-calendar";
import styles from "./calendar.module.css";
import "react-tooltip/dist/react-tooltip.css";
export type CalendarProps = ComponentPropsWithoutRef<"div"> & {
data: Activity[];
};
const Calendar = ({ data, className, ...rest }: CalendarProps) => {
// heavily inspired by https://github.com/grubersjoe/react-github-calendar
return (
<div className={clsx(styles.calendar, className)} {...rest}>
<ActivityCalendar
data={data}
colorScheme="dark"
theme={{
// this isn't actually locked to dark mode, we just take over theming using CSS like everywhere else
dark: [
"var(--activity-0)",
"var(--activity-1)",
"var(--activity-2)",
"var(--activity-3)",
"var(--activity-4)",
],
}}
labels={{
totalCount: `{{count}} contributions in the last year`,
}}
maxLevel={4}
renderBlock={(block, activity) => (
<a
href={`https://github.com/jakejarvis?tab=overview&from=${activity.date}&to=${activity.date}`}
target="_blank"
rel="noopener"
>
{cloneElement(block, {
"data-tooltip-id": "activity-tooltip",
"data-tooltip-html": `${activity.count === 0 ? "No" : activity.count} contribution${activity.count === 1 ? "" : "s"} on ${format(activity.date, "MMMM do")}`,
})}
</a>
)}
fontSize={13}
/>
<Tooltip id="activity-tooltip" className={styles.tooltip} />
</div>
);
};
export default Calendar;

View File

@@ -1,3 +1,8 @@
.heading {
font-weight: 400;
font-size: 1.4em;
}
.grid {
display: flex;
flex-flow: row wrap;

View File

@@ -1,13 +1,16 @@
import { env } from "../../lib/env";
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { graphql } from "@octokit/graphql";
import * as cheerio from "cheerio";
import { GitForkIcon, StarIcon } from "lucide-react";
import Calendar from "./calendar";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import RelativeTime from "../../components/RelativeTime";
import { addMetadata } from "../../lib/helpers/metadata";
import * as config from "../../lib/config";
import type { User } from "@octokit/graphql-schema";
import type { Repository, User } from "@octokit/graphql-schema";
import styles from "./page.module.css";
@@ -19,81 +22,185 @@ export const metadata = addMetadata({
},
});
const getRepos = async () => {
// don't fail the entire site build if the required API key for this page is missing
if (!env.GITHUB_TOKEN) {
console.warn(`ERROR: I can't fetch any GitHub projects without "GITHUB_TOKEN" set! Disabling projects page.`);
const getContributions = async (): Promise<
Array<{
date: string;
count: number;
level: number;
}>
> => {
// thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts
try {
const response = await fetch(`https://github.com/users/${config.authorSocial.github}/contributions`, {
headers: {
referer: `https://github.com/${config.authorSocial.github}`,
"x-requested-with": "XMLHttpRequest",
},
cache: "force-cache",
next: {
revalidate: 43200, // 12 hours
tags: ["github-contributions"],
},
});
// just return a 404 since this page would be blank anyways
notFound();
const $ = cheerio.load(await response.text());
const days = $(".js-calendar-graph-table .ContributionCalendar-day")
.get()
.sort((a, b) => {
const dateA = a.attribs["data-date"] ?? "";
const dateB = b.attribs["data-date"] ?? "";
return dateA.localeCompare(dateB, "en");
});
const dayTooltips = $(".js-calendar-graph tool-tip")
.toArray()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.reduce<Record<string, any>>((map, elem) => {
map[elem.attribs["for"]] = elem;
return map;
}, {});
return days.map((day) => {
const attr = {
id: day.attribs["id"],
date: day.attribs["data-date"],
level: day.attribs["data-level"],
};
let count = 0;
if (dayTooltips[attr.id]) {
const text = dayTooltips[attr.id].firstChild;
if (text) {
const countMatch = text.data.trim().match(/^\d+/);
if (countMatch) {
count = parseInt(countMatch[0]);
}
}
}
const level = parseInt(attr.level);
return {
date: attr.date,
count,
level,
};
});
} catch (error) {
console.error("[/projects] Failed to fetch contributions:", error);
return [];
}
};
// https://docs.github.com/en/graphql/reference/objects#repository
const { user } = await graphql<{ user: User }>(
`
query ($username: String!, $sort: RepositoryOrderField!, $limit: Int) {
user(login: $username) {
repositories(
first: $limit
isLocked: false
isFork: false
ownerAffiliations: OWNER
privacy: PUBLIC
orderBy: { field: $sort, direction: DESC }
) {
edges {
node {
name
url
description
pushedAt
stargazerCount
forkCount
primaryLanguage {
const getRepos = async (): Promise<Repository[] | undefined> => {
try {
// https://docs.github.com/en/graphql/reference/objects#repository
const { user } = await graphql<{ user: User }>(
`
query ($username: String!, $sort: RepositoryOrderField!, $limit: Int) {
user(login: $username) {
repositories(
first: $limit
isLocked: false
isFork: false
ownerAffiliations: OWNER
privacy: PUBLIC
orderBy: { field: $sort, direction: DESC }
) {
edges {
node {
name
color
url
description
pushedAt
stargazerCount
forkCount
primaryLanguage {
name
color
}
}
}
}
}
}
}
`,
{
username: config.authorSocial.github,
sort: "STARGAZERS",
limit: 12,
headers: {
accept: "application/vnd.github.v3+json",
authorization: `token ${env.GITHUB_TOKEN}`,
},
request: {
// override fetch() to use next's extension to cache the response
// https://nextjs.org/docs/app/api-reference/functions/fetch#fetchurl-options
fetch: (url: string | URL | Request, options?: RequestInit) => {
return fetch(url, {
...options,
cache: "force-cache",
next: {
revalidate: 600, // 10 minutes
tags: ["github-api"],
},
});
`,
{
username: config.authorSocial.github,
sort: "STARGAZERS",
limit: 12,
headers: {
accept: "application/vnd.github.v3+json",
authorization: `token ${env.GITHUB_TOKEN}`,
},
},
}
);
request: {
// override fetch() to use next's extension to cache the response
// https://nextjs.org/docs/app/api-reference/functions/fetch#fetchurl-options
fetch: (url: string | URL | Request, options?: RequestInit) => {
return fetch(url, {
...options,
cache: "force-cache",
next: {
revalidate: 1800, // 30 minutes
tags: ["github-repos"],
},
});
},
},
}
);
return user.repositories.edges?.map((edge) => edge!.node);
return user.repositories.edges?.map((edge) => edge!.node as Repository);
} catch (error) {
console.error("[/projects] Failed to fetch repositories:", error);
return [];
}
};
const Page = async () => {
const repos = await getRepos();
// 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 blank anyways
if (!env.GITHUB_TOKEN) {
console.warn("[/projects] I can't fetch anything from GitHub without 'GITHUB_TOKEN' set!");
notFound();
}
if (!config.authorSocial?.github) {
console.warn(
"[/projects] I can't fetch anything from GitHub without 'authorSocial.github' set in lib/config/index.ts."
);
notFound();
}
// fetch the repos and contributions in parallel
const [contributions, repos] = await Promise.all([getContributions(), getRepos()]);
return (
<>
<PageTitle canonical="/projects">Projects</PageTitle>
<h2 className={styles.heading}>
<Link href={`https://github.com/${config.authorSocial.github}`} style={{ color: "inherit" }} plain>
Contribution activity
</Link>
</h2>
<Suspense fallback={null}>
<Calendar data={contributions} style={{ marginBottom: "2em" }} />
</Suspense>
<h2 className={styles.heading}>
<Link
href={`https://github.com/${config.authorSocial.github}?tab=repositories&sort=stargazers`}
style={{ color: "inherit" }}
plain
>
Popular repositories
</Link>
</h2>
<div className={styles.grid}>
{repos?.map((repo) => (
<div key={repo!.name} className={styles.card}>