Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/frontend/e2e/stats-rank.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ test("selecting 'None' progress style hides the rank circle", async ({
page.locator("main").getByRole("heading", { level: 1 }),
).toContainText("Modify Card Parameters");

const preview = page.locator("#svgWrapper");
const preview = page.locator("#svg-wrapper");
const rankCircle = preview.locator('[data-testid="rank-circle"]');

// Default "Rank" progress style renders the rank circle.
Expand Down
1 change: 0 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"react-spinners": "^0.17.0",
"react-toastify": "^11.1.0",
"redux": "^5.0.1",
"save-svg-as-png": "^1.4.17",
"uuid": "^14.0.1"
},
"devDependencies": {
Expand Down
12 changes: 0 additions & 12 deletions apps/frontend/src/modules.d.ts

This file was deleted.

122 changes: 62 additions & 60 deletions apps/frontend/src/wizard/Home/stages/Display.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { useRef } from "react";
import type { JSX } from "react";
import { toast } from "react-toastify";
import { saveSvgAsPng } from "save-svg-as-png";
import type { ToastOptions } from "react-toastify";

import { HOST } from "../../../constants";
import { CardImage } from "../../components/Card/CardImage";
import { downloadSvgAsPng } from "../../components/Card/downloadSvgAsPng";
import { getCardThemeBackdrop } from "../../components/Card/themeBackdrop";
import { Button } from "../../components/Generic/Button";
import type { CardUrlBuilder } from "../../models/CardUrl";
import { useIsDarkTheme } from "../../useIsDarkTheme";

const TOAST_OPTIONS: ToastOptions = {
position: "bottom-right",
autoClose: 1500,
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: true,
draggable: false,
};

const BUTTON_CLASS = "m-4 w-60 flex justify-center";

interface DisplayStageProps {
filename: string;
link: string;
Expand All @@ -25,78 +38,66 @@ export function DisplayStage({
guestHint,
}: DisplayStageProps): JSX.Element {
const isDark = useIsDarkTheme();
const previewRef = useRef<HTMLDivElement>(null);

const downloadPNG = () => {
saveSvgAsPng(
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
?.firstElementChild as HTMLElement,
`${filename}.png`,
{
scale: 2,
encoderOptions: 1,
},
);
};

const copyMarkdown = () => {
void navigator.clipboard.writeText(
`[![GitHub Stats](${card.toApiUrl(HOST)})](${link})`,
);
toast.info("Copied to Clipboard!", {
position: "bottom-right",
autoClose: 1500,
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: true,
draggable: false,
const svg = previewRef.current?.shadowRoot?.querySelector("svg");
if (!svg) {
toast.error("The card is not ready yet.", TOAST_OPTIONS);
return;
}
downloadSvgAsPng(svg, `${filename}.png`).catch((error: unknown) => {
console.error(error);
toast.error("Could not download the card as a PNG.", TOAST_OPTIONS);
});
};

const copyUrl = () => {
void navigator.clipboard.writeText(card.toApiUrl(HOST));
toast.info("Copied to Clipboard!", {
position: "bottom-right",
autoClose: 1500,
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: true,
draggable: false,
});
const copy = (text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
toast.info("Copied to Clipboard!", TOAST_OPTIONS);
})
.catch((error: unknown) => {
console.error(error);
toast.error("Could not copy to the clipboard.", TOAST_OPTIONS);
});
};

return (
<div className="w-full flex flex-wrap">
<div className="h-auto lg:w-2/5 md:w-1/2">
<div className="p-10 rounded-sm bg-base-200">
<div className="flex flex-col items-center">
{[
{
title: "Copy Markdown",
highlight: true,
onClick: copyMarkdown,
},
{
title: "Copy URL",
highlight: false,
onClick: copyUrl,
},
{
title: "Download PNG",
highlight: false,
onClick: downloadPNG,
},
].map((item) => (
<Button
key={item.title}
variant={item.highlight ? "primary" : "soft"}
className="m-4 w-60 flex justify-center"
onClick={item.onClick}
>
{item.title}
</Button>
))}
<Button
variant="primary"
className={BUTTON_CLASS}
onClick={() => {
copy(`[![GitHub Stats](${card.toApiUrl(HOST)})](${link})`);
}}
>
Copy Markdown
</Button>
<Button
variant="soft"
className={BUTTON_CLASS}
onClick={() => {
copy(card.toApiUrl(HOST));
}}
>
Copy URL
</Button>
<Button
variant="soft"
className={BUTTON_CLASS}
onClick={downloadPNG}
>
Download PNG
</Button>
</div>
{!!guestHint && <div className="pt-10 pl-10 pr-10">{guestHint}</div>}
{!!guestHint && (
<div className="pt-10 pl-10 pr-10 text-center">{guestHint}</div>
)}
</div>
</div>
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
Expand All @@ -108,6 +109,7 @@ export function DisplayStage({
card={card.disableAnimations()}
stage={4}
className="flex justify-center"
ref={previewRef}
/>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/wizard/components/Card/CardImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { clsx } from "clsx";
import type { Ref } from "react";

import { HOST } from "../../../constants";
import type { CardUrlBuilder } from "../../models/CardUrl";
Expand All @@ -10,13 +11,16 @@ interface CardImageProps {
stage: number;
compact?: boolean;
className?: string;
/** Forwarded to `SvgInline`'s shadow-root host. */
ref?: Ref<HTMLDivElement>;
}

export const CardImage = ({
card,
stage,
compact = false,
className,
ref,
}: CardImageProps) => {
// `client=wizard` marks requests coming from the wizard preview.
const fullImageSrc = card.client("wizard").toApiUrl(HOST);
Expand All @@ -28,6 +32,7 @@ export const CardImage = ({
url={fullImageSrc}
compact={compact}
stage={stage}
ref={ref}
/>
</div>
);
Expand Down
29 changes: 22 additions & 7 deletions apps/frontend/src/wizard/components/Card/SvgInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import { router } from "@stats-organization/github-readme-stats-backend";
import { loadConfigFromEnv } from "@stats-organization/github-readme-stats-core";
import axios from "axios";
import { useEffect, useRef, useState } from "react";
import type { JSX } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { JSX, Ref, RefCallback } from "react";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";

Expand All @@ -20,6 +20,8 @@ interface SvgInlineProps {
compact?: boolean;
className?: string;
forceLoading?: boolean;
/** Receives the shadow-root host, so a caller can reach the rendered `<svg>`. */
ref?: Ref<HTMLDivElement> | undefined;
}

export function SvgInline(props: SvgInlineProps): JSX.Element {
Expand All @@ -29,11 +31,24 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
className,
compact = false,
forceLoading = false,
ref,
} = props;

const [svg, setSvg] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);

const setContainer = useCallback<RefCallback<HTMLDivElement>>(
(node) => {
containerRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
},
[ref],
);
const userToken = useUserToken();
const isAuthenticated = useIsAuthenticated();

Expand Down Expand Up @@ -117,7 +132,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
if (forceLoading || !loaded) {
if (compact) {
return (
<Skeleton key="compactSkeleton" style={{ paddingBottom: "58%" }} />
<Skeleton key="compact-skeleton" style={{ paddingBottom: "58%" }} />
);
}
// maximum dimensions of cards in SelectCard stage
Expand All @@ -132,9 +147,9 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
// Using a different key than the skeletons above to ensure react doesn't reuse the node, which would keep its old shadow DOM content visible.
return (
<div
key="svgWrapper"
ref={containerRef}
id="svgWrapper"
key="svg-wrapper"
ref={setContainer}
id="svg-wrapper"
className={className}
/>
);
Expand Down
54 changes: 54 additions & 0 deletions apps/frontend/src/wizard/components/Card/downloadSvgAsPng.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Convert a self-contained SVG element to a PNG and hand it to the browser as a download.
* The card SVGs carry their own `<style>` and reference no external assets,
* so serializing the element is enough.
*
* @param svg The `<svg>` element to convert.
* @param filename Name given to the downloaded file.
* @param scale Pixel multiplier applied to the SVG's own dimensions.
*/
export async function downloadSvgAsPng(
svg: SVGSVGElement,
filename: string,
scale = 2,
): Promise<void> {
const width = svg.width.baseVal.value || svg.viewBox.baseVal.width;
const height = svg.height.baseVal.value || svg.viewBox.baseVal.height;

const source = new XMLSerializer().serializeToString(svg);
const image = new Image(width, height);

await new Promise<void>((resolve, reject) => {
image.onload = () => {
resolve();
};
image.onerror = () => {
reject(new Error("The card could not be rendered as an image."));
};
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
});

const canvas = document.createElement("canvas");
canvas.width = Math.ceil(width * scale);
canvas.height = Math.ceil(height * scale);

const context = canvas.getContext("2d");
if (!context) {
throw new Error("This browser does not provide a 2D canvas context.");
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);

const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/png");
});
if (!blob) {
throw new Error("The card could not be encoded as a PNG.");
}

const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.