feat(web): add shareable stack page (#556)

This commit is contained in:
Aman Varshney
2025-09-01 21:58:15 +05:30
committed by GitHub
parent e8ccc07d73
commit a26ff59913
17 changed files with 1894 additions and 422 deletions

View File

@@ -15,7 +15,7 @@ import {
MessageCircleIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "../../../lib/cn";
import { cn } from "@/lib/utils";
const cache = new Map<string, string>();

View File

@@ -0,0 +1,88 @@
"use client";
import { formatHex, oklch } from "culori";
import QR from "qrcode";
import { type HTMLAttributes, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
export type QRCodeProps = HTMLAttributes<HTMLDivElement> & {
data: string;
foreground?: string;
background?: string;
robustness?: "L" | "M" | "Q" | "H";
};
const oklchRegex = /oklch\(([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\)/;
const getOklch = (color: string, fallback: [number, number, number]) => {
const oklchMatch = color.match(oklchRegex);
if (!oklchMatch) {
return { l: fallback[0], c: fallback[1], h: fallback[2] };
}
return {
l: Number.parseFloat(oklchMatch[1]),
c: Number.parseFloat(oklchMatch[2]),
h: Number.parseFloat(oklchMatch[3]),
};
};
export const QRCode = ({
data,
foreground,
background,
robustness = "M",
className,
...props
}: QRCodeProps) => {
const [svg, setSVG] = useState<string | null>(null);
useEffect(() => {
const generateQR = async () => {
try {
const styles = getComputedStyle(document.documentElement);
const foregroundColor =
foreground ?? styles.getPropertyValue("--foreground");
const backgroundColor =
background ?? styles.getPropertyValue("--background");
const foregroundOklch = getOklch(
foregroundColor,
[0.21, 0.006, 285.885],
);
const backgroundOklch = getOklch(backgroundColor, [0.985, 0, 0]);
const newSvg = await QR.toString(data, {
type: "svg",
color: {
dark: formatHex(oklch({ mode: "oklch", ...foregroundOklch })),
light: formatHex(oklch({ mode: "oklch", ...backgroundOklch })),
},
width: 200,
errorCorrectionLevel: robustness,
margin: 0,
});
setSVG(newSvg);
} catch (err) {
console.error(err);
}
};
generateQR();
}, [data, foreground, background, robustness]);
if (!svg) {
return null;
}
return (
<div
className={cn("size-full", "[&_svg]:size-full", className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required for SVG"
dangerouslySetInnerHTML={{ __html: svg }}
{...props}
/>
);
};

View File

@@ -0,0 +1,42 @@
import QR from "qrcode";
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export type QRCodeProps = HTMLAttributes<HTMLDivElement> & {
data: string;
foreground: string;
background: string;
robustness?: "L" | "M" | "Q" | "H";
};
export const QRCode = async ({
data,
foreground,
background,
robustness = "M",
className,
...props
}: QRCodeProps) => {
const svg = await QR.toString(data, {
type: "svg",
color: {
dark: foreground,
light: background,
},
width: 200,
errorCorrectionLevel: robustness,
});
if (!svg) {
throw new Error("Failed to generate QR code");
}
return (
<div
className={cn("size-full", "[&_svg]:size-full", className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required for SVG"
dangerouslySetInnerHTML={{ __html: svg }}
{...props}
/>
);
};

View File

@@ -0,0 +1,188 @@
"use client";
import { Check, Copy, Share2, Twitter } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { QRCode } from "@/components/ui/kibo-ui/qr-code";
import { TechBadge } from "@/components/ui/tech-badge";
import type { StackState } from "@/lib/constant";
import { TECH_OPTIONS } from "@/lib/constant";
import { CATEGORY_ORDER } from "@/lib/stack-utils";
import { cn } from "@/lib/utils";
interface ShareDialogProps {
children: React.ReactNode;
stackUrl: string;
stackState: StackState;
}
export function ShareDialog({
children,
stackUrl,
stackState,
}: ShareDialogProps) {
const [copied, setCopied] = useState(false);
const techBadges = (() => {
const badges: React.ReactNode[] = [];
for (const category of CATEGORY_ORDER) {
const categoryKey = category as keyof StackState;
const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS];
const selectedValue = stackState[categoryKey];
if (!options) continue;
if (Array.isArray(selectedValue)) {
if (
selectedValue.length === 0 ||
(selectedValue.length === 1 && selectedValue[0] === "none")
) {
continue;
}
for (const id of selectedValue) {
if (id === "none") continue;
const tech = options.find((opt) => opt.id === id);
if (tech) {
badges.push(
<TechBadge
key={`${category}-${tech.id}`}
icon={tech.icon}
name={tech.name}
category={category}
/>,
);
}
}
} else {
const tech = options.find((opt) => opt.id === selectedValue);
if (
!tech ||
tech.id === "none" ||
tech.id === "false" ||
((category === "git" ||
category === "install" ||
category === "auth") &&
tech.id === "true")
) {
continue;
}
badges.push(
<TechBadge
key={`${category}-${tech.id}`}
icon={tech.icon}
name={tech.name}
category={category}
/>,
);
}
}
return badges;
})();
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(stackUrl);
setCopied(true);
toast.success("Link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy link");
}
};
const shareToTwitter = () => {
const text = encodeURIComponent(
`Check out this cool tech stack I configured with Create Better T Stack!\n\n🚀 ${techBadges.length} technologies selected\n\n`,
);
const url = encodeURIComponent(stackUrl);
window.open(
`https://twitter.com/intent/tweet?text=${text}&url=${url}`,
"_blank",
);
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="h-5 w-5" />
Share Your Stack
</DialogTitle>
<DialogDescription>
Share your custom tech stack configuration with others
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<div className="font-medium text-foreground text-sm">
Technologies
</div>
<div className="flex flex-wrap gap-1.5 rounded border border-border bg-muted/20 p-3">
{techBadges.length > 0 ? (
techBadges
) : (
<span className="text-muted-foreground text-sm">
No technologies selected
</span>
)}
</div>
</div>
<div className="space-y-3">
<div className="font-medium text-foreground text-sm">QR Code</div>
<div className="flex items-center justify-center rounded border border-border bg-muted/20 p-4">
<div className="h-32 w-32">
<QRCode data={stackUrl} />
</div>
</div>
<p className="text-center text-muted-foreground text-xs">
Scan to view this tech stack
</p>
</div>
<div className="space-y-3">
<div className="font-medium text-foreground text-sm">Share</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={shareToTwitter}
className="flex-1"
>
<Twitter className="h-4 w-4" />X (Twitter)
</Button>
<Button
variant="secondary"
onClick={copyToClipboard}
className={cn(
"flex-1",
copied &&
"border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400",
)}
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copied ? "Copied!" : "Copy URL"}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
interface TechBadgeProps {
icon: string;
name: string;
category: string;
className?: string;
}
const getBadgeColors = (category: string): string => {
switch (category) {
case "webFrontend":
case "nativeFrontend":
return "border-blue-300 bg-blue-100 text-blue-800 dark:border-blue-700/30 dark:bg-blue-900/30 dark:text-blue-300";
case "runtime":
return "border-amber-300 bg-amber-100 text-amber-800 dark:border-amber-700/30 dark:bg-amber-900/30 dark:text-amber-300";
case "backend":
return "border-sky-300 bg-sky-100 text-sky-800 dark:border-sky-700/30 dark:bg-sky-900/30 dark:text-sky-300";
case "api":
return "border-indigo-300 bg-indigo-100 text-indigo-800 dark:border-indigo-700/30 dark:bg-indigo-900/30 dark:text-indigo-300";
case "database":
return "border-emerald-300 bg-emerald-100 text-emerald-800 dark:border-emerald-700/30 dark:bg-emerald-900/30 dark:text-emerald-300";
case "orm":
return "border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700/30 dark:bg-cyan-900/30 dark:text-cyan-300";
case "auth":
return "border-green-300 bg-green-100 text-green-800 dark:border-green-700/30 dark:bg-green-900/30 dark:text-green-300";
case "dbSetup":
return "border-pink-300 bg-pink-100 text-pink-800 dark:border-pink-700/30 dark:bg-pink-900/30 dark:text-pink-300";
case "addons":
return "border-violet-300 bg-violet-100 text-violet-800 dark:border-violet-700/30 dark:bg-violet-900/30 dark:text-violet-300";
case "examples":
return "border-teal-300 bg-teal-100 text-teal-800 dark:border-teal-700/30 dark:bg-teal-900/30 dark:text-teal-300";
case "packageManager":
return "border-orange-300 bg-orange-100 text-orange-800 dark:border-orange-700/30 dark:bg-orange-900/30 dark:text-orange-300";
case "git":
case "webDeploy":
case "serverDeploy":
case "install":
return "border-gray-300 bg-gray-100 text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400";
default:
return "border-gray-300 bg-gray-100 text-gray-800 dark:border-gray-700/30 dark:bg-gray-900/30 dark:text-gray-300";
}
};
function TechIcon({
icon,
name,
className,
}: {
icon: string;
name: string;
className?: string;
}) {
const { theme } = useTheme();
if (!icon) return null;
if (!icon.startsWith("https://")) {
return (
<span className={cn("inline-flex items-center text-lg", className)}>
{icon}
</span>
);
}
let iconSrc = icon;
if (
theme === "light" &&
(icon.includes("drizzle") ||
icon.includes("prisma") ||
icon.includes("express") ||
icon.includes("clerk"))
) {
iconSrc = icon.replace(".svg", "-light.svg");
}
return (
<Image
suppressHydrationWarning
src={iconSrc}
alt={`${name} icon`}
width={20}
height={20}
className={cn("inline-block", className)}
unoptimized
/>
);
}
export function TechBadge({ icon, name, category, className }: TechBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs",
getBadgeColors(category),
className,
)}
>
{icon !== "" && (
<TechIcon icon={icon} name={name} className={cn("h-3 w-3")} />
)}
{name}
</span>
);
}