diff --git a/src/components/CardGenerator.tsx b/src/components/CardGenerator.tsx index c663121..a0213d1 100644 --- a/src/components/CardGenerator.tsx +++ b/src/components/CardGenerator.tsx @@ -1,252 +1,15 @@ "use client"; -import { useState, useRef, useCallback, useEffect } from "react"; -import { createPortal } from "react-dom"; -import { toPng, toBlob } from "html-to-image"; - -import type { - CardBlockId, - CardDisplayOptions, - CardLayout, - UserSummary, -} from "@/lib/types"; -import { - cloneDefaultCardLayout, - LAYOUT_STORAGE_KEY, - normalizeCardLayout, - toggleBlockVisibility, -} from "@/lib/cardLayout"; - -import BusinessCard from "./BusinessCard"; -import LayoutEditor from "./LayoutEditor"; +import { useState } from "react"; +import type { UserSummary } from "@/lib/types"; +import CardGeneratorModal from "./CardGeneratorModal"; type Props = { summary: UserSummary; }; -const DEFAULT_DISPLAY_OPTIONS: CardDisplayOptions = { - showCompany: false, - showLocation: false, - showWebsite: false, - showTwitter: false, - showJoinedDate: false, - showTopics: false, - showContributionBreakdown: false, - showStreaks: false, - showInterests: false, - showActivityBreakdown: false, -}; - -const MAIN_BLOCKS: Array<{ id: CardBlockId; label: string }> = [ - { id: "avatar", label: "Show Avatar" }, - { id: "bio", label: "Show Bio" }, - { id: "stats", label: "Show Stats" }, - { id: "topLanguages", label: "Top Languages" }, - { id: "topRepos", label: "Top Repositories" }, -]; - -const DETAIL_OPTIONS: Array<{ key: keyof CardDisplayOptions; label: string }> = - [ - { key: "showCompany", label: "Show Company" }, - { key: "showLocation", label: "Show Location" }, - { key: "showWebsite", label: "Show Website" }, - { key: "showTwitter", label: "Show Twitter" }, - { key: "showJoinedDate", label: "Joined Date" }, - { key: "showTopics", label: "Show Topics" }, - { key: "showContributionBreakdown", label: "Contribution Details" }, - { key: "showStreaks", label: "Show Streaks" }, - { key: "showInterests", label: "Show Interests" }, - { key: "showActivityBreakdown", label: "Activity Breakdown" }, - ]; - export default function CardGenerator({ summary }: Props) { const [isOpen, setIsOpen] = useState(false); - const [previewUrl, setPreviewUrl] = useState(null); - const [isGenerating, setIsGenerating] = useState(false); - const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">( - "idle", - ); - const [mounted, setMounted] = useState(false); - const [isLayoutHydrated, setIsLayoutHydrated] = useState(false); - const [activeTab, setActiveTab] = useState<"settings" | "layout">("settings"); - - const [layout, setLayout] = useState(cloneDefaultCardLayout()); - const [displayOptions, setDisplayOptions] = useState( - DEFAULT_DISPLAY_OPTIONS, - ); - - const cardRef = useRef(null); - const modalRef = useRef(null); - const previousFocusRef = useRef(null); - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (!mounted) { - return; - } - - try { - const raw = localStorage.getItem(LAYOUT_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - setLayout(normalizeCardLayout(parsed)); - } else { - setLayout(cloneDefaultCardLayout()); - } - } catch { - setLayout(cloneDefaultCardLayout()); - } finally { - setIsLayoutHydrated(true); - } - }, [mounted]); - - useEffect(() => { - if (!mounted || !isLayoutHydrated) { - return; - } - - try { - localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout)); - } catch { - // Ignore storage write failures (private mode, quota exceeded, etc.) - } - }, [layout, mounted, isLayoutHydrated]); - - const handleClose = useCallback(() => { - setIsOpen(false); - setPreviewUrl(null); - }, []); - - const generateImage = useCallback(async () => { - if (!cardRef.current) return null; - try { - const dataUrl = await toPng(cardRef.current, { - cacheBust: true, - pixelRatio: 1, - backgroundColor: "#0d1117", - }); - return dataUrl; - } catch (err) { - console.error("Failed to generate image", err); - return null; - } - }, []); - - useEffect(() => { - if (isOpen) { - previousFocusRef.current = document.activeElement as HTMLElement; - - if (modalRef.current) { - modalRef.current.focus(); - } - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - handleClose(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - if (previousFocusRef.current) { - previousFocusRef.current.focus(); - } - }; - } - }, [isOpen, handleClose]); - - useEffect(() => { - if (isOpen && !previewUrl) { - let isCancelled = false; - setIsGenerating(true); - - const generate = async () => { - try { - await document.fonts.ready; - const url = await generateImage(); - if (!isCancelled) { - setPreviewUrl(url); - } - } catch (err) { - console.error("Failed to generate image", err); - if (!isCancelled) { - setPreviewUrl(null); - } - } finally { - if (!isCancelled) { - setIsGenerating(false); - } - } - }; - - let rafId: number; - const timer = setTimeout(() => { - rafId = requestAnimationFrame(() => { - generate(); - }); - }, 100); - - return () => { - isCancelled = true; - clearTimeout(timer); - if (rafId) cancelAnimationFrame(rafId); - }; - } - }, [isOpen, previewUrl, generateImage]); - - useEffect(() => { - if (isOpen) { - setPreviewUrl(null); - } - }, [layout, displayOptions, isOpen]); - - const handleDownload = useCallback(() => { - if (!previewUrl) return; - const link = document.createElement("a"); - link.download = `${summary.profile?.login || "github"}-summary-card.png`; - link.href = previewUrl; - link.click(); - }, [previewUrl, summary.profile?.login]); - - const handleCopy = useCallback(async () => { - if (!cardRef.current) return; - try { - setCopyStatus("idle"); - const blob = await toBlob(cardRef.current, { - cacheBust: true, - backgroundColor: "#0d1117", - }); - if (!blob) throw new Error("Failed to create blob"); - - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - setCopyStatus("copied"); - setTimeout(() => setCopyStatus("idle"), 2000); - } catch (err) { - console.error("Failed to copy", err); - setCopyStatus("error"); - } - }, []); - - const toggleMainBlockVisibility = (blockId: CardBlockId) => { - setLayout((prev) => toggleBlockVisibility(prev, blockId)); - }; - - const toggleDisplayOption = (key: keyof CardDisplayOptions) => { - setDisplayOptions((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const isBlockVisible = (blockId: CardBlockId): boolean => { - return ( - layout.blocks.find((block) => block.id === blockId)?.visible ?? false - ); - }; if (!summary.profile) return null; @@ -275,229 +38,11 @@ export default function CardGenerator({ summary }: Props) { Card - {mounted && - createPortal( - isOpen && ( - <> -
{ - if (e.key === "Enter" || e.key === " " || e.key === "Escape") { - e.preventDefault(); - handleClose(); - } - }} - > -
e.stopPropagation()} - tabIndex={-1} - > -
-

- Profile Card -

- -
- -
- - -
- - {activeTab === "settings" && ( -
- {MAIN_BLOCKS.map(({ id, label }) => ( - - ))} - - {DETAIL_OPTIONS.map(({ key, label }) => ( - - ))} -
- )} - - {activeTab === "layout" && ( -
- -
- )} - -
- {isGenerating ? ( -
-
-

Generating preview...

-
- ) : previewUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - Card Preview - ) : ( -

Failed to generate preview.

- )} -
- -
- - - -
-
-
- -
-
- -
-
- - ), - document.body, - )} + setIsOpen(false)} + /> ); } diff --git a/src/components/CardGeneratorModal.tsx b/src/components/CardGeneratorModal.tsx new file mode 100644 index 0000000..cba0037 --- /dev/null +++ b/src/components/CardGeneratorModal.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { createPortal } from "react-dom"; +import Image from "next/image"; +import { toPng, toBlob } from "html-to-image"; + +import type { + CardBlockId, + CardDisplayOptions, + CardLayout, + UserSummary, +} from "@/lib/types"; +import { + cloneDefaultCardLayout, + LAYOUT_STORAGE_KEY, + normalizeCardLayout, + toggleBlockVisibility, +} from "@/lib/cardLayout"; + +import BusinessCard from "./BusinessCard"; +import LayoutEditor from "./LayoutEditor"; + +const DEFAULT_DISPLAY_OPTIONS: CardDisplayOptions = { + showCompany: false, + showLocation: false, + showWebsite: false, + showTwitter: false, + showJoinedDate: false, + showTopics: false, + showContributionBreakdown: false, + showStreaks: false, + showInterests: false, + showActivityBreakdown: false, +}; + +const MAIN_BLOCKS: Array<{ id: CardBlockId; label: string }> = [ + { id: "avatar", label: "Show Avatar" }, + { id: "bio", label: "Show Bio" }, + { id: "stats", label: "Show Stats" }, + { id: "topLanguages", label: "Top Languages" }, + { id: "topRepos", label: "Top Repositories" }, +]; + +const DETAIL_OPTIONS: Array<{ key: keyof CardDisplayOptions; label: string }> = + [ + { key: "showCompany", label: "Show Company" }, + { key: "showLocation", label: "Show Location" }, + { key: "showWebsite", label: "Show Website" }, + { key: "showTwitter", label: "Show Twitter" }, + { key: "showJoinedDate", label: "Joined Date" }, + { key: "showTopics", label: "Show Topics" }, + { key: "showContributionBreakdown", label: "Contribution Details" }, + { key: "showStreaks", label: "Show Streaks" }, + { key: "showInterests", label: "Show Interests" }, + { key: "showActivityBreakdown", label: "Activity Breakdown" }, + ]; + +type Props = { + summary: UserSummary; + isOpen: boolean; + onClose: () => void; +}; + +export default function CardGeneratorModal({ summary, isOpen, onClose }: Props) { + const [previewUrl, setPreviewUrl] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">( + "idle", + ); + const [mounted, setMounted] = useState(false); + const [isLayoutHydrated, setIsLayoutHydrated] = useState(false); + const [activeTab, setActiveTab] = useState<"settings" | "layout">("settings"); + + const [layout, setLayout] = useState(cloneDefaultCardLayout()); + const [displayOptions, setDisplayOptions] = useState( + DEFAULT_DISPLAY_OPTIONS, + ); + + const cardRef = useRef(null); + const modalRef = useRef(null); + const previousFocusRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) { + return; + } + + try { + const raw = localStorage.getItem(LAYOUT_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + setLayout(normalizeCardLayout(parsed)); + } else { + setLayout(cloneDefaultCardLayout()); + } + } catch { + setLayout(cloneDefaultCardLayout()); + } finally { + setIsLayoutHydrated(true); + } + }, [mounted]); + + useEffect(() => { + if (!mounted || !isLayoutHydrated) { + return; + } + + try { + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout)); + } catch { + // Ignore storage write failures (private mode, quota exceeded, etc.) + } + }, [layout, mounted, isLayoutHydrated]); + + const handleClose = useCallback(() => { + onClose(); + setPreviewUrl(null); + }, [onClose]); + + const generateImage = useCallback(async () => { + if (!cardRef.current) return null; + try { + const dataUrl = await toPng(cardRef.current, { + cacheBust: true, + pixelRatio: 1, + backgroundColor: "#0d1117", + }); + return dataUrl; + } catch (err) { + console.error("Failed to generate image", err); + return null; + } + }, []); + + useEffect(() => { + if (isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement; + + if (modalRef.current) { + modalRef.current.focus(); + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + if (previousFocusRef.current) { + previousFocusRef.current.focus(); + } + }; + } + }, [isOpen, handleClose]); + + useEffect(() => { + if (isOpen && !previewUrl) { + let isCancelled = false; + setIsGenerating(true); + + const generate = async () => { + try { + await document.fonts.ready; + const url = await generateImage(); + if (!isCancelled) { + setPreviewUrl(url); + } + } catch (err) { + console.error("Failed to generate image", err); + if (!isCancelled) { + setPreviewUrl(null); + } + } finally { + if (!isCancelled) { + setIsGenerating(false); + } + } + }; + + let rafId: number; + const timer = setTimeout(() => { + rafId = requestAnimationFrame(() => { + generate(); + }); + }, 100); + + return () => { + isCancelled = true; + clearTimeout(timer); + if (rafId) cancelAnimationFrame(rafId); + }; + } + }, [isOpen, previewUrl, generateImage]); + + useEffect(() => { + if (isOpen) { + setPreviewUrl(null); + } + }, [layout, displayOptions, isOpen]); + + const handleDownload = useCallback(() => { + if (!previewUrl) return; + const link = document.createElement("a"); + link.download = `${summary.profile?.login || "github"}-summary-card.png`; + link.href = previewUrl; + link.click(); + }, [previewUrl, summary.profile?.login]); + + const handleCopy = useCallback(async () => { + if (!cardRef.current) return; + try { + setCopyStatus("idle"); + const blob = await toBlob(cardRef.current, { + cacheBust: true, + backgroundColor: "#0d1117", + }); + if (!blob) throw new Error("Failed to create blob"); + + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": blob }), + ]); + setCopyStatus("copied"); + setTimeout(() => setCopyStatus("idle"), 2000); + } catch (err) { + console.error("Failed to copy", err); + setCopyStatus("error"); + } + }, []); + + const toggleMainBlockVisibility = (blockId: CardBlockId) => { + setLayout((prev) => toggleBlockVisibility(prev, blockId)); + }; + + const toggleDisplayOption = (key: keyof CardDisplayOptions) => { + setDisplayOptions((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const isBlockVisible = (blockId: CardBlockId): boolean => { + return ( + layout.blocks.find((block) => block.id === blockId)?.visible ?? false + ); + }; + + if (!isOpen || !mounted) return null; + + return createPortal( + <> +
{ + if (e.key === "Enter" || e.key === " " || e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }} + > +
e.stopPropagation()} + tabIndex={-1} + > +
+

+ Profile Card +

+ +
+ +
+ + +
+ + {activeTab === "settings" && ( +
+ {MAIN_BLOCKS.map(({ id, label }) => ( + + ))} + + {DETAIL_OPTIONS.map(({ key, label }) => ( + + ))} +
+ )} + + {activeTab === "layout" && ( +
+ +
+ )} + +
+ {isGenerating ? ( +
+
+

Generating preview...

+
+ ) : previewUrl ? ( + Card Preview + ) : ( +

Failed to generate preview.

+ )} +
+ +
+ + + +
+
+
+ +
+
+ +
+
+ , + document.body, + ); +} diff --git a/src/components/DashboardSettingsClient.tsx b/src/components/DashboardSettingsClient.tsx index e6ef1ca..9c1526c 100644 --- a/src/components/DashboardSettingsClient.tsx +++ b/src/components/DashboardSettingsClient.tsx @@ -1,10 +1,11 @@ "use client"; -import { useMemo } from "react"; import { useState } from "react"; import { useSession } from "next-auth/react"; import LayoutEditor from "@/components/LayoutEditor"; +import DisplayOptionsSection from "@/components/DisplayOptionsSection"; +import ReadmeCardUrlSection from "@/components/ReadmeCardUrlSection"; import { getDefaultCardSettings, loadCardSettings, @@ -12,19 +13,6 @@ import { } from "@/lib/cardSettings"; import type { CardBlockId, CardDisplayOptions, CardLayout } from "@/lib/types"; -const toggles: Array<{ key: keyof CardDisplayOptions; label: string }> = [ - { key: "showCompany", label: "Company" }, - { key: "showLocation", label: "Location" }, - { key: "showWebsite", label: "Website" }, - { key: "showTwitter", label: "Twitter" }, - { key: "showJoinedDate", label: "Joined date" }, - { key: "showTopics", label: "Topics" }, - { key: "showContributionBreakdown", label: "Contribution breakdown" }, - { key: "showStreaks", label: "Streaks" }, - { key: "showInterests", label: "Interests" }, - { key: "showActivityBreakdown", label: "Activity breakdown" }, -]; - export default function DashboardSettingsClient() { const { data: session } = useSession(); const [layout, setLayout] = useState( @@ -34,11 +22,6 @@ export default function DashboardSettingsClient() { () => loadCardSettings().options, ); const [status, setStatus] = useState(""); - const [readmeTheme, setReadmeTheme] = useState<"light" | "dark">("light"); - const [readmeCols, setReadmeCols] = useState<1 | 2>(1); - const [includeStreak, setIncludeStreak] = useState(false); - const [includeHeatmap, setIncludeHeatmap] = useState(false); - const [copyState, setCopyState] = useState(""); const onSave = () => { saveCardSettings(layout, options); @@ -62,106 +45,6 @@ export default function DashboardSettingsClient() { })); }; - const readmeUrl = useMemo(() => { - const username = session?.user?.login; - if (!username) { - return ""; - } - - const blockMap: Record< - CardBlockId, - "bio" | "stats" | "langs" | "repos" | null - > = { - avatar: null, - bio: "bio", - stats: "stats", - topLanguages: "langs", - topRepos: "repos", - }; - - const selected = layout.blocks - .filter((block) => block.visible) - .map((block) => blockMap[block.id]) - .filter((block): block is "bio" | "stats" | "langs" | "repos" => - Boolean(block), - ); - - const selectedBlocks: Array< - "bio" | "stats" | "langs" | "repos" | "streak" | "heatmap" - > = [...selected]; - - if (includeStreak) { - selectedBlocks.push("streak"); - } - - if (includeHeatmap) { - selectedBlocks.push("heatmap"); - } - - const uniqueBlocks = Array.from(new Set(selectedBlocks)); - - const layoutParts = layout.blocks - .filter((block) => block.visible && blockMap[block.id]) - .map((block) => { - const target = blockMap[block.id]; - if (!target) { - return null; - } - return `${block.column}:${target}`; - }) - .filter((value): value is string => Boolean(value)); - - const hide = []; - if (options.showContributionBreakdown === false) { - hide.push("stars"); - } - if (options.showActivityBreakdown === false) { - hide.push("forks"); - } - - const params = new URLSearchParams(); - params.set("format", "png"); - params.set("theme", readmeTheme); - params.set("cols", String(readmeCols)); - params.set( - "blocks", - uniqueBlocks.length > 0 ? uniqueBlocks.join(",") : "bio,stats,langs", - ); - if (layoutParts.length > 0) { - params.set("layout", layoutParts.join(",")); - } - if (hide.length > 0) { - params.set("hide", hide.join(",")); - } - params.set("width", "600"); - - const origin = typeof window !== "undefined" ? window.location.origin : ""; - return `${origin}/api/card/${encodeURIComponent(username)}?${params.toString()}`; - }, [ - session?.user?.login, - layout.blocks, - options.showContributionBreakdown, - options.showActivityBreakdown, - readmeTheme, - readmeCols, - includeStreak, - includeHeatmap, - ]); - - const onCopyReadmeUrl = async () => { - if (!readmeUrl) { - setCopyState("Sign in to generate URL"); - return; - } - - try { - await navigator.clipboard.writeText(readmeUrl); - setCopyState("Copied!"); - } catch { - setCopyState("Copy failed"); - } - }; - return (
@@ -182,97 +65,13 @@ export default function DashboardSettingsClient() { /> -
-

Display Options

-
- {toggles.map((toggle) => { - const checked = Boolean(options[toggle.key]); - return ( - - ); - })} -
-
- -
-

README Card URL

-
- - - - -
+ -
- {readmeUrl || "Sign in to generate your README URL"} -
- -
- - {copyState ? ( - {copyState} - ) : null} -
-
+
+ {copyState ? ( + {copyState} + ) : null} +
+ + ); +}