diff --git a/.gitignore b/.gitignore
index 5205805..a5bac02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+package-lock.json
\ No newline at end of file
diff --git a/src/pages/index.js b/src/pages/index.js
index 587de8c..99484dd 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -1,7 +1,8 @@
-import { TbBrandTwitter, TbShare, TbDownload, TbCopy } from "react-icons/tb";
-import React, { useRef, useState, useEffect } from "react";
+import { TbBrandTwitter, TbShare, TbDownload, TbCopy, TbChevronDown } from "react-icons/tb";
+import React, { useRef, useState, useEffect, useCallback } from "react";
import {
download,
+ downloadSVG,
fetchData,
downloadJSON,
cleanUsername,
@@ -19,6 +20,8 @@ const App = () => {
const [theme, setTheme] = useState("standard");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
+ const [showDownloadMenu, setShowDownloadMenu] = useState(false);
+ const downloadMenuRef = useRef();
useEffect(() => {
if (!data) {
@@ -27,6 +30,17 @@ const App = () => {
draw();
}, [data, theme]);
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handler = (e) => {
+ if (downloadMenuRef.current && !downloadMenuRef.current.contains(e.target)) {
+ setShowDownloadMenu(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
const handleSubmit = (e) => {
e.preventDefault();
@@ -62,6 +76,18 @@ const App = () => {
copyToClipboard(canvasRef.current);
};
+ const onDownloadSVG = (e) => {
+ e.preventDefault();
+ if (data != null) {
+ downloadSVG(
+ data,
+ username,
+ theme,
+ "Made by @sallar & friends - github-contributions.vercel.app"
+ );
+ }
+ };
+
const onDownloadJson = (e) => {
e.preventDefault();
if (data != null) {
@@ -139,14 +165,35 @@ const App = () => {
Copy
-
+
+
+ {showDownloadMenu && (
+
+
+
+
+ )}
+
{global.navigator && "share" in navigator && (
/g, ">")
+ .replace(/"/g, """);
+}
+
+/**
+ * Build an array of week-column dates for a given year entry.
+ * Identical logic to the canvas library.
+ */
+function buildGraphEntries(year, contributions) {
+ const DATE_FMT = (d) => d.toISOString().slice(0, 10);
+ const addWeeks = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n * 7); return r; };
+ const setDay = (d, n) => { const r = new Date(d); r.setDate(r.getDate() - r.getDay() + n); return r; };
+
+ const today = new Date();
+ const thisYear = String(today.getFullYear());
+ const lastDate = year.year === thisYear ? today : new Date(year.range.end);
+ const firstReal = new Date(`${year.year}-01-01`);
+ // startOfWeek (Sunday)
+ const firstDate = setDay(firstReal, 0);
+
+ const contribMap = {};
+ contributions.forEach(c => { contribMap[c.date] = c; });
+
+ const firstRowDates = [];
+ let nextDate = new Date(firstDate);
+ while (nextDate <= lastDate) {
+ const dateStr = DATE_FMT(nextDate);
+ firstRowDates.push({ date: dateStr, info: contribMap[dateStr] || null });
+ nextDate = addWeeks(nextDate, 1);
+ }
+
+ const graphEntries = [firstRowDates];
+ for (let i = 1; i < 7; i++) {
+ graphEntries.push(
+ firstRowDates.map(({ date }) => {
+ const base = new Date(date);
+ const r = setDay(base, i);
+ const dateStr = DATE_FMT(r);
+ return { date: dateStr, info: contribMap[dateStr] || null };
+ })
+ );
+ }
+ return { graphEntries, lastDate };
+}
+
+/**
+ * Generate a pure SVG string for the contributions chart.
+ * Faithfully mirrors github-contributions-canvas drawing logic.
+ */
+export function generateContributionsSVG(data, username, themeName = "standard", footerText = "") {
+ const theme = THEMES[themeName] || THEMES.standard;
+ const years = data.years;
+ const contribs = data.contributions;
+
+ const totalContributions = years.reduce((s, y) => s + (y.total || 0), 0);
+
+ const svgWidth = 53 * (BOX_WIDTH + BOX_MARGIN) + CANVAS_MARGIN * 2;
+ const svgHeight = years.length * YEAR_HEIGHT + CANVAS_MARGIN + HEADER_HEIGHT + 10;
+
+ const els = []; // SVG element strings
+
+ // ── Background ────────────────────────────────────────────────────────────
+ els.push(``);
+
+ // ── Header ────────────────────────────────────────────────────────────────
+ // Username
+ els.push(
+ `` +
+ `@${escXml(username)} on GitHub`
+ );
+ // Total contributions
+ els.push(
+ `` +
+ `Total Contributions: ${totalContributions}`
+ );
+
+ // Divider line (y = 55 + 10 = 65)
+ const dividerY = 65;
+ els.push(
+ ``
+ );
+
+ // Legend ("Less" / boxes / "More") — top-right, y ≈ 37
+ const legendY = 37;
+ const legendBoxY = TEXT_HEIGHT + BOX_WIDTH; // 25
+ let themeGrades = 5;
+ const lessX = svgWidth - CANVAS_MARGIN - (BOX_WIDTH + BOX_MARGIN) * themeGrades - 55;
+ els.push(
+ `Less`
+ );
+ els.push(
+ `More`
+ );
+ themeGrades = 5;
+ for (let x = 0; x < 5; x++) {
+ const bx = svgWidth - CANVAS_MARGIN - (BOX_WIDTH + BOX_MARGIN) * themeGrades - 27;
+ els.push(
+ ``
+ );
+ themeGrades -= 1;
+ }
+
+ // Footer text
+ if (footerText) {
+ els.push(
+ `` +
+ `${escXml(footerText)}`
+ );
+ }
+
+ // ── Year rows ─────────────────────────────────────────────────────────────
+ const today = new Date();
+ const thisYear = String(today.getFullYear());
+
+ years.forEach((year, i) => {
+ const offsetY = YEAR_HEIGHT * i + CANVAS_MARGIN + HEADER_HEIGHT + 10;
+ const offsetX = CANVAS_MARGIN;
+
+ // Year label
+ const isCurrent = year.year === thisYear;
+ const label =
+ `${year.year}: ${year.total} Contribution${year.total === 1 ? "" : ""}` +
+ (isCurrent ? " (so far)" : "");
+ els.push(
+ `` +
+ `${escXml(label)}`
+ );
+
+ const { graphEntries, lastDate } = buildGraphEntries(year, contribs);
+
+ // Day cells
+ for (let row = 0; row < graphEntries.length; row++) {
+ for (let col = 0; col < graphEntries[row].length; col++) {
+ const day = graphEntries[row][col];
+ if (!day.info) continue;
+ const cellDate = new Date(day.date);
+ if (cellDate > lastDate) continue;
+
+ const intensity = parseInt(day.info.intensity, 10) || 0;
+ const color = theme[`grade${intensity}`];
+ const cx = offsetX + (BOX_WIDTH + BOX_MARGIN) * col;
+ const cy = offsetY + TEXT_HEIGHT + (BOX_WIDTH + BOX_MARGIN) * row;
+ els.push(``);
+ }
+ }
+
+ // Month labels
+ let lastCountedMonth = 0;
+ for (let col = 0; col < graphEntries[0].length; col++) {
+ const d = new Date(graphEntries[0][col].date);
+ const month = d.getMonth() + 1;
+ const firstMonthIsDec = month === 12 && col === 0;
+ if (month !== lastCountedMonth && !firstMonthIsDec) {
+ const monthNames = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
+ const mx = offsetX + (BOX_WIDTH + BOX_MARGIN) * col;
+ els.push(
+ `` +
+ `${monthNames[month - 1]}`
+ );
+ lastCountedMonth = month;
+ }
+ }
+ });
+
+ return [
+ ``,
+ ``
+ ].join("\n");
+}
+
+export function downloadSVG(data, username, themeName, footerText) {
+ try {
+ const svgContent = generateContributionsSVG(data, username, themeName, footerText);
+ const blob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ document.body.insertAdjacentElement("beforeend", a);
+ a.download = "contributions.svg";
+ a.href = url;
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error(err);
+ }
+}
+
export function cleanUsername(username) {
return username.replace(/^(http|https):\/\/(?!www\.)github\.com\//, "");
}
diff --git a/yarn.lock b/yarn.lock
index e6db0ce..15d6d99 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7,71 +7,11 @@
resolved "https://registry.npmjs.org/@next/env/-/env-13.1.1.tgz"
integrity sha512-vFMyXtPjSAiOXOywMojxfKIqE3VWN5RCAx+tT3AS3pcKjMLFTCJFUWsKv8hC+87Z1F4W3r68qTwDFZIFmd5Xkw==
-"@next/swc-android-arm-eabi@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.1.1.tgz#b5c3cd1f79d5c7e6a3b3562785d4e5ac3555b9e1"
- integrity sha512-qnFCx1kT3JTWhWve4VkeWuZiyjG0b5T6J2iWuin74lORCupdrNukxkq9Pm+Z7PsatxuwVJMhjUoYz7H4cWzx2A==
-
-"@next/swc-android-arm64@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-13.1.1.tgz#e2ca9ccbba9ef770cb19fbe96d1ac00fe4cb330d"
- integrity sha512-eCiZhTzjySubNqUnNkQCjU3Fh+ep3C6b5DCM5FKzsTH/3Gr/4Y7EiaPZKILbvnXmhWtKPIdcY6Zjx51t4VeTfA==
-
-"@next/swc-darwin-arm64@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.1.1.tgz#4af00877332231bbd5a3703435fdd0b011e74767"
- integrity sha512-9zRJSSIwER5tu9ADDkPw5rIZ+Np44HTXpYMr0rkM656IvssowPxmhK0rTreC1gpUCYwFsRbxarUJnJsTWiutPg==
-
-"@next/swc-darwin-x64@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.1.1.tgz#bf4cb09e7e6ec6d91e031118dde2dd17078bcbbc"
- integrity sha512-qWr9qEn5nrnlhB0rtjSdR00RRZEtxg4EGvicIipqZWEyayPxhUu6NwKiG8wZiYZCLfJ5KWr66PGSNeDMGlNaiA==
-
-"@next/swc-freebsd-x64@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.1.1.tgz#6933ea1264328e8523e28818f912cd53824382d4"
- integrity sha512-UwP4w/NcQ7V/VJEj3tGVszgb4pyUCt3lzJfUhjDMUmQbzG9LDvgiZgAGMYH6L21MoyAATJQPDGiAMWAPKsmumA==
-
-"@next/swc-linux-arm-gnueabihf@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.1.1.tgz#b5896967aaba3873d809c3ad2e2039e89acde419"
- integrity sha512-CnsxmKHco9sosBs1XcvCXP845Db+Wx1G0qouV5+Gr+HT/ZlDYEWKoHVDgnJXLVEQzq4FmHddBNGbXvgqM1Gfkg==
-
-"@next/swc-linux-arm64-gnu@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.1.1.tgz#91b3e9ea8575b1ded421c0ea0739b7bccf228469"
- integrity sha512-JfDq1eri5Dif+VDpTkONRd083780nsMCOKoFG87wA0sa4xL8LGcXIBAkUGIC1uVy9SMsr2scA9CySLD/i+Oqiw==
-
-"@next/swc-linux-arm64-musl@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.1.1.tgz#83149ea05d7d55f3664d608dbe004c0d125f9147"
- integrity sha512-GA67ZbDq2AW0CY07zzGt07M5b5Yaq5qUpFIoW3UFfjOPgb0Sqf3DAW7GtFMK1sF4ROHsRDMGQ9rnT0VM2dVfKA==
-
"@next/swc-linux-x64-gnu@13.1.1":
version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.1.1.tgz#d7d0777b56de0dd82b78055772e13e18594a15ca"
+ resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.1.1.tgz"
integrity sha512-nnjuBrbzvqaOJaV+XgT8/+lmXrSCOt1YYZn/irbDb2fR2QprL6Q7WJNgwsZNxiLSfLdv+2RJGGegBx9sLBEzGA==
-"@next/swc-linux-x64-musl@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.1.1.tgz#41655722b127133cd95ab5bc8ca1473e9ab6876f"
- integrity sha512-CM9xnAQNIZ8zf/igbIT/i3xWbQZYaF397H+JroF5VMOCUleElaMdQLL5riJml8wUfPoN3dtfn2s4peSr3azz/g==
-
-"@next/swc-win32-arm64-msvc@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.1.1.tgz#f10da3dfc9b3c2bbd202f5d449a9b807af062292"
- integrity sha512-pzUHOGrbgfGgPlOMx9xk3QdPJoRPU+om84hqVoe6u+E0RdwOG0Ho/2UxCgDqmvpUrMab1Deltlt6RqcXFpnigQ==
-
-"@next/swc-win32-ia32-msvc@13.1.1":
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.1.1.tgz#4c0102b9b18ece15c818056d07e3917ee9dade78"
- integrity sha512-WeX8kVS46aobM9a7Xr/kEPcrTyiwJqQv/tbw6nhJ4fH9xNZ+cEcyPoQkwPo570dCOLz3Zo9S2q0E6lJ/EAUOBg==
-
-"@next/swc-win32-x64-msvc@13.1.1":
- version "13.1.1"
- resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.1.1.tgz"
- integrity sha512-mVF0/3/5QAc5EGVnb8ll31nNvf3BWpPY4pBb84tk+BfQglWLqc5AC9q1Ht/YMWiEgs8ALNKEQ3GQnbY0bJF2Gg==
-
"@swc/helpers@0.4.14":
version "0.4.14"
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz"
@@ -86,7 +26,7 @@
boolbase@^1.0.0:
version "1.0.0"
- resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+ resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
caniuse-lite@^1.0.30001406:
@@ -139,9 +79,14 @@ css-what@^4.0.0:
resolved "https://registry.npmjs.org/css-what/-/css-what-4.0.0.tgz"
integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A==
+csstype@^3.0.10:
+ version "3.2.3"
+ resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz"
+ integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+
date-fns@^2.29.3:
version "2.29.3"
- resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8"
+ resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz"
integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
dom-serializer@^1.0.1, dom-serializer@~1.2.0:
@@ -174,26 +119,21 @@ domutils@^2.4.3, domutils@^2.4.4:
domelementtype "^2.0.1"
domhandler "^4.0.0"
-entities@^2.0.0:
- version "2.2.0"
- resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz"
- integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
-
-entities@~2.1.0:
+entities@^2.0.0, entities@~2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz"
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
github-contributions-canvas@^0.8.0:
version "0.8.0"
- resolved "https://registry.yarnpkg.com/github-contributions-canvas/-/github-contributions-canvas-0.8.0.tgz#629932db914fd3a28cca05260be8a8bf3497a5e3"
+ resolved "https://registry.npmjs.org/github-contributions-canvas/-/github-contributions-canvas-0.8.0.tgz"
integrity sha512-qFwTRW+qnLKjKaVUk4YsVCvh6ymBab1lt4wx2Tg/d7J/eeEaMnbWAsmjFNWs4mjJ1ptD4pqlUA8fUw2s/ZBMjg==
dependencies:
date-fns "^2.29.3"
goober@^2.1.10:
version "2.1.12"
- resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.12.tgz#6c1645314ac9a68fe76408e1f502c63df8a39042"
+ resolved "https://registry.npmjs.org/goober/-/goober-2.1.12.tgz"
integrity sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q==
htmlparser2@^6.0.0:
@@ -260,7 +200,7 @@ normalize.css@^8.0.1:
nth-check@^2.0.0:
version "2.1.1"
- resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
+ resolved "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz"
integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
dependencies:
boolbase "^1.0.0"
@@ -268,7 +208,7 @@ nth-check@^2.0.0:
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
- integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+ integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
parse5-htmlparser2-tree-adapter@^6.0.0:
version "6.0.1"
@@ -305,7 +245,7 @@ prop-types@^15.7.2:
object-assign "^4.1.1"
react-is "^16.8.1"
-react-dom@^18.2.0:
+react-dom@^18.2.0, react-dom@>=16:
version "18.2.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
@@ -322,7 +262,7 @@ react-hot-toast@^2.4.0:
react-icons@^4.7.1:
version "4.7.1"
- resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.7.1.tgz#0f4b25a5694e6972677cb189d2a72eabea7a8345"
+ resolved "https://registry.npmjs.org/react-icons/-/react-icons-4.7.1.tgz"
integrity sha512-yHd3oKGMgm7zxo3EA7H2n7vxSoiGmHk5t6Ou4bXsfcgWyhfDKMpyKfhHR6Bjnn63c+YXBLBPUql9H4wPJM6sXw==
react-is@^16.8.1:
@@ -330,7 +270,7 @@ react-is@^16.8.1:
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
-react@^18.2.0:
+react@*, react@^16.8||^17||^18, react@^18.2.0, "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", react@>=16:
version "18.2.0"
resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==