fix(web): improve builder logic (#560)

This commit is contained in:
Aman Varshney
2025-09-03 13:49:33 +05:30
committed by GitHub
parent 2affdeb210
commit 85f7ac2dcb
15 changed files with 2195 additions and 1960 deletions

0
NUQS_MIGRATION.md Normal file
View File

View File

@@ -36,7 +36,7 @@
"motion": "^12.23.12", "motion": "^12.23.12",
"next": "15.3.5", "next": "15.3.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.4.3", "nuqs": "^2.5.2",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"posthog-js": "^1.258.5", "posthog-js": "^1.258.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

View File

@@ -0,0 +1,34 @@
export 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";
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import Image from "next/image";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
export 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
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +1,42 @@
"use client"; "use client";
import { Check, ChevronDown, Copy, Edit, Share2, Terminal } from "lucide-react"; import { Check, Copy, Edit, Share2, Terminal } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ShareDialog } from "@/components/ui/share-dialog"; import { ShareDialog } from "@/components/ui/share-dialog";
import { TechBadge } from "@/components/ui/tech-badge"; import { TechBadge } from "@/components/ui/tech-badge";
import { type StackState, TECH_OPTIONS } from "@/lib/constant"; import { type StackState, TECH_OPTIONS } from "@/lib/constant";
import type { LoadedStackState } from "@/lib/stack-server"; import type { LoadedStackState } from "@/lib/stack-url-state";
import { import {
CATEGORY_ORDER, CATEGORY_ORDER,
generateStackCommand,
generateStackSharingUrl,
generateStackSummary, generateStackSummary,
generateStackUrl, generateStackUrlFromState,
} from "@/lib/stack-utils"; } from "@/lib/stack-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import PackageIcon from "../../_components/icons";
interface StackDisplayProps { interface StackDisplayProps {
stackState: LoadedStackState; stackState: LoadedStackState;
} }
export function StackDisplay({ stackState }: StackDisplayProps) { export function StackDisplay({ stackState }: StackDisplayProps) {
const pathname = usePathname();
const searchParamsHook = useSearchParams();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [selectedPM, setSelectedPM] = useState<"npm" | "pnpm" | "bun">("bun"); const [stackUrl, setStackUrl] = useState<string>("");
const [editUrl, setEditUrl] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
setStackUrl(generateStackSharingUrl(stackState, window.location.origin));
setEditUrl(generateStackUrlFromState(stackState, window.location.origin));
}
}, [stackState]);
const stackUrl = generateStackUrl(pathname, searchParamsHook);
const stack = stackState; const stack = stackState;
const stackSummary = generateStackSummary(stack); const stackSummary = generateStackSummary(stack);
const commands = { const command = generateStackCommand(stackState);
npm: "npx create-better-t-stack@latest",
pnpm: "pnpm create better-t-stack@latest",
bun: "bun create better-t-stack@latest",
};
const command = commands[selectedPM];
const techBadges = (() => { const techBadges = (() => {
const badges: React.ReactNode[] = []; const badges: React.ReactNode[] = [];
@@ -117,102 +109,107 @@ export function StackDisplay({ stackState }: StackDisplayProps) {
return ( return (
<main className="mx-auto min-h-svh max-w-[1280px]"> <main className="mx-auto min-h-svh max-w-[1280px]">
<div className="mx-auto flex flex-col gap-8 px-4 pt-12"> <div className="mx-auto flex flex-col gap-8 px-4 pt-12">
<div className="mb-8"> <div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div className="flex items-center gap-2">
<div className="space-y-2"> <Terminal className="h-5 w-5 text-primary" />
<h1 className="font-bold text-4xl text-foreground">Tech Stack</h1> <span className="font-bold text-lg sm:text-xl">
<p className="text-lg text-muted-foreground">{stackSummary}</p> STACK_DISPLAY.SH
</div> </span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[{techBadges.length} DEPENDENCIES]
</span>
</div>
<div className="flex items-center gap-3"> <div className="space-y-2 rounded border border-border bg-muted/20 p-4">
<Link href={`/new?${searchParamsHook.toString()}`}> <div className="flex items-center gap-2 text-sm">
<Button variant="outline" size="sm"> <span className="text-primary">$</span>
<Edit className="h-4 w-4" /> <span className="text-foreground">./display_stack --summary</span>
Edit Stack </div>
</Button> <div className="flex items-center gap-2 text-sm">
</Link> <span className="text-primary">&gt;</span>
<span className="text-muted-foreground">{stackSummary}</span>
<ShareDialog stackUrl={stackUrl} stackState={stackState}> </div>
<Button variant="outline" size="sm"> <div className="flex items-center gap-2 text-sm">
<Share2 className="h-4 w-4" /> <span className="text-primary">$</span>
Share <span className="text-muted-foreground">
</Button> Stack loaded successfully
</ShareDialog> </span>
</div>
</div> </div>
</div> </div>
<div className="mb-8"> <div className="flex items-center gap-3">
<div className="rounded border border-border p-4"> <Link href={editUrl}>
<div className="mb-4 flex items-center justify-between"> <button
<div className="flex items-center gap-2"> type="button"
<Terminal className="h-4 w-4 text-primary" /> className="flex items-center gap-2 rounded border border-border bg-fd-background px-3 py-2 font-mono text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
<span className="font-semibold text-sm">GENERATE_COMMAND</span> >
</div> <Edit className="h-3 w-3" />
<DropdownMenu> <span>./edit --stack</span>
<DropdownMenuTrigger asChild> </button>
<button </Link>
type="button"
className="flex items-center gap-2 rounded border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted/10"
>
<PackageIcon pm={selectedPM} className="h-3 w-3" />
<span>{selectedPM.toUpperCase()}</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(["bun", "pnpm", "npm"] as const).map((pm) => (
<DropdownMenuItem
key={pm}
onClick={() => setSelectedPM(pm)}
className={cn(
"flex items-center gap-2",
selectedPM === pm && "bg-accent text-background",
)}
>
<PackageIcon pm={pm} className="h-3 w-3" />
<span>{pm.toUpperCase()}</span>
{selectedPM === pm && (
<Check className="ml-auto h-3 w-3 text-background" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center justify-between rounded border border-border p-3"> <ShareDialog stackUrl={stackUrl} stackState={stackState}>
<div className="flex items-center gap-2 font-mono text-sm"> <button
<span className="text-primary">$</span> type="button"
<span className="text-foreground">{command}</span> className="flex items-center gap-2 rounded border border-border bg-fd-background px-3 py-2 font-mono text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
</div> >
<button <Share2 className="h-3 w-3" />
type="button" <span>./share --config</span>
onClick={copyCommand} </button>
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-muted/10" </ShareDialog>
> </div>
{copied ? (
<Check className="h-3 w-3 text-primary" /> <div className="space-y-4">
) : ( <div className="flex items-center gap-2">
<Copy className="h-3 w-3" /> <span className="text-primary text-xs"></span>
)} <span className="font-mono font-semibold text-foreground text-sm">
{copied ? "COPIED!" : "COPY"} GENERATE_COMMAND
</button> </span>
</div>
<div className="flex items-center justify-between rounded border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2 font-mono text-sm">
<span className="text-primary">$</span>
<span className="text-foreground">{command}</span>
</div> </div>
<button
type="button"
onClick={copyCommand}
className={cn(
"flex items-center gap-1 rounded border px-2 py-1 font-mono text-xs transition-colors",
copied
? "border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400"
: "border-border hover:bg-muted/10",
)}
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "COPIED!" : "COPY"}
</button>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h2 className="font-semibold text-2xl text-foreground"> <div className="flex items-center gap-2">
Technologies <span className="text-primary text-xs"></span>
</h2> <span className="font-mono font-semibold text-foreground text-sm">
<div className="flex flex-wrap gap-3"> DEPENDENCIES ({techBadges.length})
{techBadges.length > 0 ? ( </span>
techBadges
) : (
<p className="text-muted-foreground">No technologies selected</p>
)}
</div> </div>
{techBadges.length > 0 ? (
<div className="flex flex-wrap gap-3">{techBadges}</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-primary">$</span>
<span>No technologies selected</span>
</div>
)}
</div> </div>
</div> </div>
</main> </main>

View File

@@ -1,35 +1,45 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react"; import { Suspense } from "react";
import { loadStackParams } from "@/lib/stack-server"; import { loadStackParams, serializeStackParams } from "@/lib/stack-url-state";
import { StackDisplay } from "./_components/stack-display"; import { StackDisplay } from "./_components/stack-display";
interface StackPageProps { interface StackPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
} }
export const metadata: Metadata = { export async function generateMetadata({
title: "Tech Stack - Better-T-Stack", searchParams,
description: "View and share your custom tech stack configuration", }: StackPageProps): Promise<Metadata> {
openGraph: { const params = await loadStackParams(searchParams);
title: "Tech Stack - Better-T-Stack", const projectName = params.projectName || "my-better-t-app";
const title = `${projectName} Better-T-Stack`;
return {
title,
description: "View and share your custom tech stack configuration", description: "View and share your custom tech stack configuration",
url: "https://better-t-stack.dev/stack", alternates: {
images: [ canonical: serializeStackParams("/stack", params),
{ },
url: "https://r2.better-t-stack.dev/og.png", openGraph: {
width: 1200, title,
height: 630, description: "View and share your custom tech stack configuration",
alt: "Better-T-Stack Tech Stack", url: "https://better-t-stack.dev/stack",
}, images: [
], {
}, url: "https://r2.better-t-stack.dev/og.png",
twitter: { width: 1200,
card: "summary_large_image", height: 630,
title: "Tech Stack - Better-T-Stack", alt: "Better-T-Stack Tech Stack",
description: "View and share your custom tech stack configuration", },
images: ["https://r2.better-t-stack.dev/og.png"], ],
}, },
}; twitter: {
card: "summary_large_image",
title,
description: "View and share your custom tech stack configuration",
images: ["https://r2.better-t-stack.dev/og.png"],
},
};
}
export default async function StackPage({ searchParams }: StackPageProps) { export default async function StackPage({ searchParams }: StackPageProps) {
const stackState = await loadStackParams(searchParams); const stackState = await loadStackParams(searchParams);

View File

@@ -1,9 +1,11 @@
"use client"; "use client";
import { Check, Copy, Share2, Twitter } from "lucide-react"; import { Check, Copy, Terminal, Twitter } from "lucide-react";
import { useState } from "react"; import Image from "next/image";
import { useTheme } from "next-themes";
import QRCode from "qrcode";
import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,7 +14,6 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { QRCode } from "@/components/ui/kibo-ui/qr-code";
import { TechBadge } from "@/components/ui/tech-badge"; import { TechBadge } from "@/components/ui/tech-badge";
import type { StackState } from "@/lib/constant"; import type { StackState } from "@/lib/constant";
import { TECH_OPTIONS } from "@/lib/constant"; import { TECH_OPTIONS } from "@/lib/constant";
@@ -31,6 +32,8 @@ export function ShareDialog({
stackState, stackState,
}: ShareDialogProps) { }: ShareDialogProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>("");
const { resolvedTheme } = useTheme();
const techBadges = (() => { const techBadges = (() => {
const badges: React.ReactNode[] = []; const badges: React.ReactNode[] = [];
@@ -111,74 +114,173 @@ export function ShareDialog({
); );
}; };
// Generate QR code using local qrcode library
useEffect(() => {
const generateQRCode = async () => {
try {
const isDark = resolvedTheme === "dark";
const dataUrl = await QRCode.toDataURL(stackUrl, {
width: 128,
margin: 2,
color: {
dark: isDark ? "#cdd6f4" : "#11111b",
light: isDark ? "#11111b" : "#ffffff",
},
});
setQrCodeDataUrl(dataUrl);
} catch (error) {
console.error("Failed to generate QR code:", error);
setQrCodeDataUrl("");
}
};
if (stackUrl) {
generateQRCode();
}
}, [stackUrl, resolvedTheme]);
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="grid grid-cols-1 bg-fd-background sm:max-w-md">
<DialogHeader> <DialogHeader className="border-border border-b pb-4">
<DialogTitle className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Share2 className="h-5 w-5" /> <Terminal className="h-4 w-4 text-primary" />
Share Your Stack <DialogTitle className="font-mono font-semibold text-foreground text-sm">
</DialogTitle> SHARE_STACK.SH
<DialogDescription> </DialogTitle>
Share your custom tech stack configuration with others </div>
<DialogDescription className="font-mono text-muted-foreground text-xs">
$ ./share_configuration --export
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3"> <div className="rounded border border-border">
<div className="font-medium text-foreground text-sm"> <div className="border-border border-b px-3 py-2">
Technologies <div className="flex items-center gap-2">
</div> <span className="text-primary text-xs"></span>
<div className="flex flex-wrap gap-1.5 rounded border border-border bg-muted/20 p-3"> <span className="font-mono font-semibold text-foreground text-xs">
{techBadges.length > 0 ? ( DEPENDENCIES.LIST
techBadges
) : (
<span className="text-muted-foreground text-sm">
No technologies selected
</span> </span>
)} <div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
</div> <span></span>
</div> <span>{techBadges.length} PACKAGES</span>
</div>
<div className="space-y-3"> </div>
<div className="font-medium text-foreground text-sm">QR Code</div> </div>
<div className="flex items-center justify-center rounded border border-border bg-muted/20 p-4"> <div className="p-3">
<div className="h-32 w-32"> <div className="flex flex-wrap gap-1.5">
<QRCode data={stackUrl} /> {techBadges.length > 0 ? (
techBadges
) : (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<span className="text-primary">$</span>
<span>No technologies selected</span>
</div>
)}
</div> </div>
</div> </div>
<p className="text-center text-muted-foreground text-xs">
Scan to view this tech stack
</p>
</div> </div>
<div className="space-y-3"> <div className="rounded border border-border">
<div className="font-medium text-foreground text-sm">Share</div> <div className="border-border border-b px-3 py-2">
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button <span className="text-primary text-xs"></span>
variant="secondary" <span className="font-mono font-semibold text-foreground text-xs">
onClick={shareToTwitter} QR_CODE.PNG
className="flex-1" </span>
> </div>
<Twitter className="h-4 w-4" />X (Twitter) </div>
</Button> <div className="p-4">
<Button <div className="flex items-center justify-center rounded border border-border bg-muted/20 p-4">
variant="secondary" <div className="flex h-32 w-32 items-center justify-center">
onClick={copyToClipboard} {qrCodeDataUrl ? (
className={cn( <Image
"flex-1", src={qrCodeDataUrl}
copied && width={128}
"border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400", height={128}
)} alt="QR Code for stack configuration"
> className="h-full w-full object-contain"
{copied ? ( />
<Check className="h-4 w-4" /> ) : (
) : ( <div className="flex flex-col items-center gap-2 text-muted-foreground text-xs">
<Copy className="h-4 w-4" /> <span className="text-primary">$</span>
)} <span>Generating QR code...</span>
{copied ? "Copied!" : "Copy URL"} </div>
</Button> )}
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-2 text-muted-foreground text-xs">
<span className="text-primary">$</span>
<span>scan --url stack_config</span>
</div>
</div>
</div>
<div className="rounded border border-border">
<div className="border-border border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-primary text-xs"></span>
<span className="font-mono font-semibold text-foreground text-xs">
EXPORT_ACTIONS.SH
</span>
</div>
</div>
<div className="p-3">
<div className="grid gap-2">
<button
type="button"
onClick={shareToTwitter}
className="flex items-center gap-2 rounded border border-border bg-fd-background px-3 py-2 font-mono text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
>
<Twitter className="h-3 w-3" />
<span className="text-primary">$</span>
<span>./share --platform twitter</span>
</button>
<button
type="button"
onClick={copyToClipboard}
className={cn(
"flex items-center gap-2 rounded border px-3 py-2 font-mono text-xs transition-all",
copied
? "border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400"
: "border-border bg-fd-background text-muted-foreground hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground",
)}
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
<span className="text-primary">$</span>
<span>
{copied
? "./copy --status success"
: "./copy --url clipboard"}
</span>
</button>
</div>
</div>
</div>
<div className="rounded border border-border">
<div className="border-border border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-primary text-xs"></span>
<span className="font-mono font-semibold text-foreground text-xs">
OUTPUT.URL
</span>
</div>
</div>
<div className="p-3">
<div className="flex items-center gap-2 text-xs">
<span className="text-primary">$</span>
<code className="flex-1 truncate text-muted-foreground">
{stackUrl}
</code>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -705,7 +705,7 @@ export const PRESET_TEMPLATES = [
]; ];
export type StackState = { export type StackState = {
projectName: string; projectName: string | null;
webFrontend: string[]; webFrontend: string[];
nativeFrontend: string[]; nativeFrontend: string[];
runtime: string; runtime: string;

View File

@@ -0,0 +1,22 @@
import type { UrlKeys } from "nuqs";
import type { StackState } from "@/lib/constant";
export const stackUrlKeys: UrlKeys<Record<keyof StackState, unknown>> = {
projectName: "name",
webFrontend: "fe-w",
nativeFrontend: "fe-n",
runtime: "rt",
backend: "be",
api: "api",
database: "db",
orm: "orm",
dbSetup: "dbs",
auth: "au",
packageManager: "pm",
addons: "add",
examples: "ex",
git: "git",
install: "i",
webDeploy: "wd",
serverDeploy: "sd",
};

View File

@@ -1,18 +1,21 @@
"use client";
import { import {
createLoader,
parseAsArrayOf, parseAsArrayOf,
parseAsString, parseAsString,
parseAsStringEnum, parseAsStringEnum,
} from "nuqs/server"; useQueryStates,
} from "nuqs";
import { DEFAULT_STACK, type StackState, TECH_OPTIONS } from "@/lib/constant"; import { DEFAULT_STACK, type StackState, TECH_OPTIONS } from "@/lib/constant";
import { stackUrlKeys } from "./stack-url-keys";
const getValidIds = (category: keyof typeof TECH_OPTIONS): string[] => { const getValidIds = (category: keyof typeof TECH_OPTIONS): string[] => {
return TECH_OPTIONS[category]?.map((opt) => opt.id) ?? []; return TECH_OPTIONS[category]?.map((opt) => opt.id) ?? [];
}; };
// Server-side parsers (same as client-side but imported from nuqs/server) export const stackParsers = {
const serverStackParsers = { projectName: parseAsString.withDefault(
projectName: parseAsString.withDefault(DEFAULT_STACK.projectName), DEFAULT_STACK.projectName ?? "my-better-t-app",
),
webFrontend: parseAsArrayOf(parseAsString).withDefault( webFrontend: parseAsArrayOf(parseAsString).withDefault(
DEFAULT_STACK.webFrontend, DEFAULT_STACK.webFrontend,
), ),
@@ -60,6 +63,26 @@ const serverStackParsers = {
).withDefault(DEFAULT_STACK.serverDeploy), ).withDefault(DEFAULT_STACK.serverDeploy),
}; };
export const loadStackParams = createLoader(serverStackParsers); export const stackQueryStatesOptions = {
history: "replace" as const,
shallow: false,
urlKeys: stackUrlKeys,
clearOnDefault: true,
};
export type LoadedStackState = Awaited<ReturnType<typeof loadStackParams>>; export function useStackState() {
const [stack, setStack] = useQueryStates(
stackParsers,
stackQueryStatesOptions,
);
const updateStack = async (
updates: Partial<StackState> | ((prev: StackState) => Partial<StackState>),
) => {
const newStack = typeof updates === "function" ? updates(stack) : updates;
const finalStack = { ...stack, ...newStack };
await setStack(finalStack);
};
return [stack, updateStack] as const;
}

View File

@@ -1,87 +1,80 @@
import { import {
parseAsArrayOf, createLoader,
parseAsString, createSerializer,
parseAsStringEnum, parseAsArrayOf as parseAsArrayOfServer,
parseAsStringEnum as parseAsStringEnumServer,
parseAsString as parseAsStringServer,
type UrlKeys, type UrlKeys,
} from "nuqs"; } from "nuqs/server";
import { DEFAULT_STACK, type StackState, TECH_OPTIONS } from "@/lib/constant"; import { DEFAULT_STACK, type StackState, TECH_OPTIONS } from "@/lib/constant";
import { stackUrlKeys } from "@/lib/stack-url-keys";
const getValidIds = (category: keyof typeof TECH_OPTIONS): string[] => { const getValidIds = (category: keyof typeof TECH_OPTIONS): string[] => {
return TECH_OPTIONS[category]?.map((opt) => opt.id) ?? []; return TECH_OPTIONS[category]?.map((opt) => opt.id) ?? [];
}; };
export const stackParsers = { const serverStackParsers = {
projectName: parseAsString.withDefault(DEFAULT_STACK.projectName), projectName: parseAsStringServer.withDefault(
webFrontend: parseAsArrayOf(parseAsString).withDefault( DEFAULT_STACK.projectName || "my-better-t-app",
),
webFrontend: parseAsArrayOfServer(parseAsStringServer).withDefault(
DEFAULT_STACK.webFrontend, DEFAULT_STACK.webFrontend,
), ),
nativeFrontend: parseAsArrayOf(parseAsString).withDefault( nativeFrontend: parseAsArrayOfServer(parseAsStringServer).withDefault(
DEFAULT_STACK.nativeFrontend, DEFAULT_STACK.nativeFrontend,
), ),
runtime: parseAsStringEnum<StackState["runtime"]>( runtime: parseAsStringEnumServer<StackState["runtime"]>(
getValidIds("runtime"), getValidIds("runtime"),
).withDefault(DEFAULT_STACK.runtime), ).withDefault(DEFAULT_STACK.runtime),
backend: parseAsStringEnum<StackState["backend"]>( backend: parseAsStringEnumServer<StackState["backend"]>(
getValidIds("backend"), getValidIds("backend"),
).withDefault(DEFAULT_STACK.backend), ).withDefault(DEFAULT_STACK.backend),
api: parseAsStringEnum<StackState["api"]>(getValidIds("api")).withDefault( api: parseAsStringEnumServer<StackState["api"]>(
DEFAULT_STACK.api, getValidIds("api"),
), ).withDefault(DEFAULT_STACK.api),
database: parseAsStringEnum<StackState["database"]>( database: parseAsStringEnumServer<StackState["database"]>(
getValidIds("database"), getValidIds("database"),
).withDefault(DEFAULT_STACK.database), ).withDefault(DEFAULT_STACK.database),
orm: parseAsStringEnum<StackState["orm"]>(getValidIds("orm")).withDefault( orm: parseAsStringEnumServer<StackState["orm"]>(
DEFAULT_STACK.orm, getValidIds("orm"),
), ).withDefault(DEFAULT_STACK.orm),
dbSetup: parseAsStringEnum<StackState["dbSetup"]>( dbSetup: parseAsStringEnumServer<StackState["dbSetup"]>(
getValidIds("dbSetup"), getValidIds("dbSetup"),
).withDefault(DEFAULT_STACK.dbSetup), ).withDefault(DEFAULT_STACK.dbSetup),
auth: parseAsStringEnum<StackState["auth"]>(getValidIds("auth")).withDefault( auth: parseAsStringEnumServer<StackState["auth"]>(
DEFAULT_STACK.auth, getValidIds("auth"),
), ).withDefault(DEFAULT_STACK.auth),
packageManager: parseAsStringEnum<StackState["packageManager"]>( packageManager: parseAsStringEnumServer<StackState["packageManager"]>(
getValidIds("packageManager"), getValidIds("packageManager"),
).withDefault(DEFAULT_STACK.packageManager), ).withDefault(DEFAULT_STACK.packageManager),
addons: parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.addons), addons: parseAsArrayOfServer(parseAsStringServer).withDefault(
examples: parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.examples), DEFAULT_STACK.addons,
git: parseAsStringEnum<StackState["git"]>(["true", "false"]).withDefault(
DEFAULT_STACK.git,
), ),
install: parseAsStringEnum<StackState["install"]>([ examples: parseAsArrayOfServer(parseAsStringServer).withDefault(
DEFAULT_STACK.examples,
),
git: parseAsStringEnumServer<StackState["git"]>([
"true",
"false",
]).withDefault(DEFAULT_STACK.git),
install: parseAsStringEnumServer<StackState["install"]>([
"true", "true",
"false", "false",
]).withDefault(DEFAULT_STACK.install), ]).withDefault(DEFAULT_STACK.install),
webDeploy: parseAsStringEnum<StackState["webDeploy"]>( webDeploy: parseAsStringEnumServer<StackState["webDeploy"]>(
getValidIds("webDeploy"), getValidIds("webDeploy"),
).withDefault(DEFAULT_STACK.webDeploy), ).withDefault(DEFAULT_STACK.webDeploy),
serverDeploy: parseAsStringEnum<StackState["serverDeploy"]>( serverDeploy: parseAsStringEnumServer<StackState["serverDeploy"]>(
getValidIds("serverDeploy"), getValidIds("serverDeploy"),
).withDefault(DEFAULT_STACK.serverDeploy), ).withDefault(DEFAULT_STACK.serverDeploy),
}; };
export const stackUrlKeys: UrlKeys<typeof stackParsers> = { export const loadStackParams = createLoader(serverStackParsers, {
projectName: "name", urlKeys: stackUrlKeys as UrlKeys<typeof serverStackParsers>,
webFrontend: "fe-w", });
nativeFrontend: "fe-n",
runtime: "rt",
backend: "be",
api: "api",
database: "db",
orm: "orm",
dbSetup: "dbs",
auth: "au",
packageManager: "pm",
addons: "add",
examples: "ex",
git: "git",
install: "i",
webDeploy: "wd",
serverDeploy: "sd",
};
export const stackQueryStatesOptions = { export const serializeStackParams = createSerializer(serverStackParsers, {
history: "replace" as const, urlKeys: stackUrlKeys as UrlKeys<typeof serverStackParsers>,
shallow: false, });
urlKeys: stackUrlKeys,
clearOnDefault: true, export type LoadedStackState = Awaited<ReturnType<typeof loadStackParams>>;
};

View File

@@ -1,21 +1,10 @@
import {
parseAsArrayOf,
parseAsString,
parseAsStringEnum,
useQueryState,
useQueryStates,
} from "nuqs";
import { import {
DEFAULT_STACK, DEFAULT_STACK,
isStackDefault, isStackDefault,
type StackState, type StackState,
TECH_OPTIONS, TECH_OPTIONS,
} from "@/lib/constant"; } from "@/lib/constant";
import { import { stackUrlKeys } from "@/lib/stack-url-keys";
stackParsers,
stackQueryStatesOptions,
stackUrlKeys,
} from "@/lib/stack-url-state";
const CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [ const CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [
"webFrontend", "webFrontend",
@@ -36,48 +25,6 @@ const CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [
"install", "install",
]; ];
const getStackKeyFromUrlKey = (urlKey: string): keyof StackState | null =>
(Object.entries(stackUrlKeys).find(
([, value]) => value === urlKey,
)?.[0] as keyof StackState) || null;
const isDefaultStack = (stack: StackState): boolean =>
Object.entries(DEFAULT_STACK).every(
([key, _defaultValue]) =>
key === "projectName" ||
isStackDefault(
stack,
key as keyof StackState,
stack[key as keyof StackState],
),
);
export function parseSearchParamsToStack(
searchParams: Record<string, string | string[] | undefined>,
): StackState {
const parsedStack: StackState = { ...DEFAULT_STACK };
Object.entries(searchParams)
.filter(([key]) => !key.startsWith("utm_"))
.forEach(([key, value]) => {
const stackKey = getStackKeyFromUrlKey(key);
if (stackKey && value !== undefined) {
try {
const parser = stackParsers[stackKey];
if (parser) {
parsedStack[stackKey] = parser.parseServerSide(
Array.isArray(value) ? value[0] : value,
) as never;
}
} catch (error) {
console.warn(`Failed to parse ${key}:`, error);
}
}
});
return parsedStack;
}
export function generateStackSummary(stack: StackState): string { export function generateStackSummary(stack: StackState): string {
const selectedTechs = CATEGORY_ORDER.flatMap((category) => { const selectedTechs = CATEGORY_ORDER.flatMap((category) => {
const options = TECH_OPTIONS[category]; const options = TECH_OPTIONS[category];
@@ -98,7 +45,7 @@ export function generateStackSummary(stack: StackState): string {
.filter(Boolean) as string[]; .filter(Boolean) as string[];
}; };
return getTechNames(selectedValue); return selectedValue ? getTechNames(selectedValue) : [];
}); });
return selectedTechs.length > 0 ? selectedTechs.join(" • ") : "Custom stack"; return selectedTechs.length > 0 ? selectedTechs.join(" • ") : "Custom stack";
@@ -117,7 +64,17 @@ export function generateStackCommand(stack: StackState): string {
] || packageManagerCommands.default; ] || packageManagerCommands.default;
const projectName = stack.projectName || "my-better-t-app"; const projectName = stack.projectName || "my-better-t-app";
if (isDefaultStack(stack)) { const isStackDefaultExceptProjectName = Object.entries(DEFAULT_STACK).every(
([key, _defaultValue]) =>
key === "projectName" ||
isStackDefault(
stack,
key as keyof StackState,
stack[key as keyof StackState],
),
);
if (isStackDefaultExceptProjectName) {
return `${base} ${projectName} --yes`; return `${base} ${projectName} --yes`;
} }
@@ -165,170 +122,46 @@ export function generateStackCommand(stack: StackState): string {
return `${base} ${projectName} ${flags.join(" ")}`; return `${base} ${projectName} ${flags.join(" ")}`;
} }
// URL generation functions
export function generateStackUrl(
pathname: string,
searchParams: URLSearchParams,
): string {
const searchString = searchParams.toString();
return `https://better-t-stack.dev${pathname}${searchString ? `?${searchString}` : ""}`;
}
export function generateStackUrlFromState( export function generateStackUrlFromState(
stack: StackState, stack: StackState,
baseUrl?: string, baseUrl?: string,
): string { ): string {
const origin = const origin = baseUrl || "https://better-t-stack.dev";
baseUrl ||
(typeof window !== "undefined"
? window.location.origin
: "https://better-t-stack.dev");
if (isDefaultStack(stack)) {
return `${origin}/stack`;
}
const stackParams = new URLSearchParams(); const stackParams = new URLSearchParams();
Object.entries(stackUrlKeys).forEach(([stackKey, urlKey]) => { Object.entries(stackUrlKeys).forEach(([stackKey, urlKey]) => {
const value = stack[stackKey as keyof StackState]; const value = stack[stackKey as keyof StackState];
if (value !== undefined) { if (value !== undefined) {
stackParams.set( stackParams.set(
urlKey, urlKey as string,
Array.isArray(value) ? value.join(",") : String(value), Array.isArray(value) ? value.join(",") : String(value),
); );
} }
}); });
return `${origin}/stack?${stackParams.toString()}`; const searchString = stackParams.toString();
return `${origin}/new${searchString ? `?${searchString}` : ""}`;
} }
// Primary hook - simplified approach export function generateStackSharingUrl(
export function useStackState() { stack: StackState,
const [stack, setStack] = useQueryStates( baseUrl?: string,
stackParsers, ): string {
stackQueryStatesOptions, const origin = baseUrl || "https://better-t-stack.dev";
);
const updateStack = async ( const stackParams = new URLSearchParams();
updates: Partial<StackState> | ((prev: StackState) => Partial<StackState>), Object.entries(stackUrlKeys).forEach(([stackKey, urlKey]) => {
) => { const value = stack[stackKey as keyof StackState];
const newStack = typeof updates === "function" ? updates(stack) : updates; if (value !== undefined) {
const finalStack = { ...stack, ...newStack }; stackParams.set(
urlKey as string,
Array.isArray(value) ? value.join(",") : String(value),
);
}
});
await setStack(isDefaultStack(finalStack) ? null : finalStack); const searchString = stackParams.toString();
}; return `${origin}/stack${searchString ? `?${searchString}` : ""}`;
return [stack, updateStack] as const;
}
// Individual state hook - kept for backward compatibility but simplified
export function useIndividualStackStates() {
const getValidIds = (category: keyof typeof TECH_OPTIONS) =>
TECH_OPTIONS[category]?.map((opt) => opt.id) ?? [];
// Individual query states
const queryStates = {
projectName: useQueryState(
"name",
parseAsString.withDefault(DEFAULT_STACK.projectName),
),
webFrontend: useQueryState(
"fe-w",
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.webFrontend),
),
nativeFrontend: useQueryState(
"fe-n",
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.nativeFrontend),
),
runtime: useQueryState(
"rt",
parseAsStringEnum(getValidIds("runtime")).withDefault(
DEFAULT_STACK.runtime,
),
),
backend: useQueryState(
"be",
parseAsStringEnum(getValidIds("backend")).withDefault(
DEFAULT_STACK.backend,
),
),
api: useQueryState(
"api",
parseAsStringEnum(getValidIds("api")).withDefault(DEFAULT_STACK.api),
),
database: useQueryState(
"db",
parseAsStringEnum(getValidIds("database")).withDefault(
DEFAULT_STACK.database,
),
),
orm: useQueryState(
"orm",
parseAsStringEnum(getValidIds("orm")).withDefault(DEFAULT_STACK.orm),
),
dbSetup: useQueryState(
"dbs",
parseAsStringEnum(getValidIds("dbSetup")).withDefault(
DEFAULT_STACK.dbSetup,
),
),
auth: useQueryState(
"au",
parseAsStringEnum(getValidIds("auth")).withDefault(DEFAULT_STACK.auth),
),
packageManager: useQueryState(
"pm",
parseAsStringEnum(getValidIds("packageManager")).withDefault(
DEFAULT_STACK.packageManager,
),
),
addons: useQueryState(
"add",
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.addons),
),
examples: useQueryState(
"ex",
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.examples),
),
git: useQueryState(
"git",
parseAsStringEnum(["true", "false"] as const).withDefault(
DEFAULT_STACK.git as "true" | "false",
),
),
install: useQueryState(
"i",
parseAsStringEnum(["true", "false"] as const).withDefault(
DEFAULT_STACK.install as "true" | "false",
),
),
webDeploy: useQueryState(
"wd",
parseAsStringEnum(getValidIds("webDeploy")).withDefault(
DEFAULT_STACK.webDeploy,
),
),
serverDeploy: useQueryState(
"sd",
parseAsStringEnum(getValidIds("serverDeploy")).withDefault(
DEFAULT_STACK.serverDeploy,
),
),
};
const stack: StackState = Object.fromEntries(
Object.entries(queryStates).map(([key, [value]]) => [key, value]),
) as StackState;
const setStack = async (updates: Partial<StackState>) => {
const promises = Object.entries(updates).map(([key, value]) => {
const setter = queryStates[key as keyof typeof queryStates]?.[1];
return setter?.(value as never);
});
await Promise.all(promises.filter(Boolean));
};
return [stack, setStack] as const;
} }
export { CATEGORY_ORDER }; export { CATEGORY_ORDER };

View File

@@ -69,7 +69,7 @@
"motion": "^12.23.12", "motion": "^12.23.12",
"next": "15.3.5", "next": "15.3.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.4.3", "nuqs": "^2.5.2",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"posthog-js": "^1.258.5", "posthog-js": "^1.258.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -2097,7 +2097,7 @@
"number-flow": ["number-flow@0.5.8", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA=="], "number-flow": ["number-flow@0.5.8", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA=="],
"nuqs": ["nuqs@2.5.1", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^6 || ^7", "react-router-dom": "^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-YvAyI01gaEfS6U2iTcfffKccGkqYRnGmLoCHvDjK4ShgtB0tKmYgC7+ez9PmdaiDmrLR+y1qHzfQC66T0VFwWQ=="], "nuqs": ["nuqs@2.5.2", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^6 || ^7", "react-router-dom": "^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-vzKeoYlMRmNYPWECdn53Nmh/jM+r/iSezEin342EVXPogT6KzALwdnYbZxASE5vTdXRUtOymtPkgsarLipKetg=="],
"nypm": ["nypm@0.6.1", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.2.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w=="], "nypm": ["nypm@0.6.1", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.2.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w=="],