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:
@@ -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,
|
||||
|
@@ -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>;
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
};
|
||||
|
33
app/projects/calendar.module.css
Normal file
33
app/projects/calendar.module.css
Normal 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
59
app/projects/calendar.tsx
Normal 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;
|
@@ -1,3 +1,8 @@
|
||||
.heading {
|
||||
font-weight: 400;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
|
@@ -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}>
|
||||
|
Reference in New Issue
Block a user