mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat(web): add shareable stack page (#556)
This commit is contained in:
@@ -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>();
|
||||
|
||||
|
||||
88
apps/web/src/components/ui/kibo-ui/qr-code/index.tsx
Normal file
88
apps/web/src/components/ui/kibo-ui/qr-code/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
42
apps/web/src/components/ui/kibo-ui/qr-code/server.tsx
Normal file
42
apps/web/src/components/ui/kibo-ui/qr-code/server.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
188
apps/web/src/components/ui/share-dialog.tsx
Normal file
188
apps/web/src/components/ui/share-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
apps/web/src/components/ui/tech-badge.tsx
Normal file
109
apps/web/src/components/ui/tech-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user