mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
fix(web): improve builder logic (#560)
This commit is contained in:
0
NUQS_MIGRATION.md
Normal file
0
NUQS_MIGRATION.md
Normal 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",
|
||||||
|
|||||||
34
apps/web/src/app/(home)/new/_components/get-badge-color.ts
Normal file
34
apps/web/src/app/(home)/new/_components/get-badge-color.ts
Normal 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
48
apps/web/src/app/(home)/new/_components/tech-icon.tsx
Normal file
48
apps/web/src/app/(home)/new/_components/tech-icon.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1596
apps/web/src/app/(home)/new/_components/utils.ts
Normal file
1596
apps/web/src/app/(home)/new/_components/utils.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">></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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
22
apps/web/src/lib/stack-url-keys.ts
Normal file
22
apps/web/src/lib/stack-url-keys.ts
Normal 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",
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -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=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user