mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat(web): add shareable stack page (#556)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
220
apps/web/src/app/(home)/stack/_components/stack-display.tsx
Normal file
220
apps/web/src/app/(home)/stack/_components/stack-display.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown, Copy, Edit, Share2, Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
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 { TechBadge } from "@/components/ui/tech-badge";
|
||||
import { type StackState, TECH_OPTIONS } from "@/lib/constant";
|
||||
import type { LoadedStackState } from "@/lib/stack-server";
|
||||
import {
|
||||
CATEGORY_ORDER,
|
||||
generateStackSummary,
|
||||
generateStackUrl,
|
||||
} from "@/lib/stack-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import PackageIcon from "../../_components/icons";
|
||||
|
||||
interface StackDisplayProps {
|
||||
stackState: LoadedStackState;
|
||||
}
|
||||
|
||||
export function StackDisplay({ stackState }: StackDisplayProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParamsHook = useSearchParams();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [selectedPM, setSelectedPM] = useState<"npm" | "pnpm" | "bun">("bun");
|
||||
|
||||
const stackUrl = generateStackUrl(pathname, searchParamsHook);
|
||||
const stack = stackState;
|
||||
const stackSummary = generateStackSummary(stack);
|
||||
|
||||
const commands = {
|
||||
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 badges: React.ReactNode[] = [];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const categoryKey = category as keyof StackState;
|
||||
const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS];
|
||||
const selectedValue = stack[categoryKey];
|
||||
|
||||
if (!options) continue;
|
||||
|
||||
if (Array.isArray(selectedValue)) {
|
||||
if (
|
||||
selectedValue.length === 0 ||
|
||||
(selectedValue.length === 1 && selectedValue[0] === "none")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of selectedValue) {
|
||||
if (id === "none") continue;
|
||||
const tech = options.find((opt) => opt.id === id);
|
||||
if (tech) {
|
||||
badges.push(
|
||||
<TechBadge
|
||||
key={`${category}-${tech.id}`}
|
||||
icon={tech.icon}
|
||||
name={tech.name}
|
||||
category={category}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tech = options.find((opt) => opt.id === selectedValue);
|
||||
if (
|
||||
!tech ||
|
||||
tech.id === "none" ||
|
||||
tech.id === "false" ||
|
||||
((category === "git" ||
|
||||
category === "install" ||
|
||||
category === "auth") &&
|
||||
tech.id === "true")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
badges.push(
|
||||
<TechBadge
|
||||
key={`${category}-${tech.id}`}
|
||||
icon={tech.icon}
|
||||
name={tech.name}
|
||||
category={category}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return badges;
|
||||
})();
|
||||
|
||||
const copyCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
toast.success("Command copied to clipboard!");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Failed to copy command");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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="mb-8">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-bold text-4xl text-foreground">Tech Stack</h1>
|
||||
<p className="text-lg text-muted-foreground">{stackSummary}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/new?${searchParamsHook.toString()}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit Stack
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<ShareDialog stackUrl={stackUrl} stackState={stackState}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
</ShareDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="rounded border border-border p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-primary" />
|
||||
<span className="font-semibold text-sm">GENERATE_COMMAND</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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">
|
||||
<div className="flex items-center gap-2 font-mono text-sm">
|
||||
<span className="text-primary">$</span>
|
||||
<span className="text-foreground">{command}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCommand}
|
||||
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-muted/10"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-primary" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
{copied ? "COPIED!" : "COPY"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold text-2xl text-foreground">
|
||||
Technologies
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{techBadges.length > 0 ? (
|
||||
techBadges
|
||||
) : (
|
||||
<p className="text-muted-foreground">No technologies selected</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/app/(home)/stack/page.tsx
Normal file
42
apps/web/src/app/(home)/stack/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { loadStackParams } from "@/lib/stack-server";
|
||||
import { StackDisplay } from "./_components/stack-display";
|
||||
|
||||
interface StackPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tech Stack - Better-T-Stack",
|
||||
description: "View and share your custom tech stack configuration",
|
||||
openGraph: {
|
||||
title: "Tech Stack - Better-T-Stack",
|
||||
description: "View and share your custom tech stack configuration",
|
||||
url: "https://better-t-stack.dev/stack",
|
||||
images: [
|
||||
{
|
||||
url: "https://r2.better-t-stack.dev/og.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Better-T-Stack Tech Stack",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Tech Stack - Better-T-Stack",
|
||||
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) {
|
||||
const stackState = await loadStackParams(searchParams);
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<StackDisplay stackState={stackState} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "../../../lib/cn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
|
||||
88
apps/web/src/components/ui/kibo-ui/qr-code/index.tsx
Normal file
88
apps/web/src/components/ui/kibo-ui/qr-code/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { formatHex, oklch } from "culori";
|
||||
import QR from "qrcode";
|
||||
import { type HTMLAttributes, useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type QRCodeProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: string;
|
||||
foreground?: string;
|
||||
background?: string;
|
||||
robustness?: "L" | "M" | "Q" | "H";
|
||||
};
|
||||
|
||||
const oklchRegex = /oklch\(([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\)/;
|
||||
|
||||
const getOklch = (color: string, fallback: [number, number, number]) => {
|
||||
const oklchMatch = color.match(oklchRegex);
|
||||
|
||||
if (!oklchMatch) {
|
||||
return { l: fallback[0], c: fallback[1], h: fallback[2] };
|
||||
}
|
||||
|
||||
return {
|
||||
l: Number.parseFloat(oklchMatch[1]),
|
||||
c: Number.parseFloat(oklchMatch[2]),
|
||||
h: Number.parseFloat(oklchMatch[3]),
|
||||
};
|
||||
};
|
||||
|
||||
export const QRCode = ({
|
||||
data,
|
||||
foreground,
|
||||
background,
|
||||
robustness = "M",
|
||||
className,
|
||||
...props
|
||||
}: QRCodeProps) => {
|
||||
const [svg, setSVG] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const foregroundColor =
|
||||
foreground ?? styles.getPropertyValue("--foreground");
|
||||
const backgroundColor =
|
||||
background ?? styles.getPropertyValue("--background");
|
||||
|
||||
const foregroundOklch = getOklch(
|
||||
foregroundColor,
|
||||
[0.21, 0.006, 285.885],
|
||||
);
|
||||
const backgroundOklch = getOklch(backgroundColor, [0.985, 0, 0]);
|
||||
|
||||
const newSvg = await QR.toString(data, {
|
||||
type: "svg",
|
||||
color: {
|
||||
dark: formatHex(oklch({ mode: "oklch", ...foregroundOklch })),
|
||||
light: formatHex(oklch({ mode: "oklch", ...backgroundOklch })),
|
||||
},
|
||||
width: 200,
|
||||
errorCorrectionLevel: robustness,
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
setSVG(newSvg);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [data, foreground, background, robustness]);
|
||||
|
||||
if (!svg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("size-full", "[&_svg]:size-full", className)}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required for SVG"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
42
apps/web/src/components/ui/kibo-ui/qr-code/server.tsx
Normal file
42
apps/web/src/components/ui/kibo-ui/qr-code/server.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import QR from "qrcode";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type QRCodeProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: string;
|
||||
foreground: string;
|
||||
background: string;
|
||||
robustness?: "L" | "M" | "Q" | "H";
|
||||
};
|
||||
|
||||
export const QRCode = async ({
|
||||
data,
|
||||
foreground,
|
||||
background,
|
||||
robustness = "M",
|
||||
className,
|
||||
...props
|
||||
}: QRCodeProps) => {
|
||||
const svg = await QR.toString(data, {
|
||||
type: "svg",
|
||||
color: {
|
||||
dark: foreground,
|
||||
light: background,
|
||||
},
|
||||
width: 200,
|
||||
errorCorrectionLevel: robustness,
|
||||
});
|
||||
|
||||
if (!svg) {
|
||||
throw new Error("Failed to generate QR code");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("size-full", "[&_svg]:size-full", className)}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required for SVG"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
188
apps/web/src/components/ui/share-dialog.tsx
Normal file
188
apps/web/src/components/ui/share-dialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy, Share2, Twitter } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { QRCode } from "@/components/ui/kibo-ui/qr-code";
|
||||
import { TechBadge } from "@/components/ui/tech-badge";
|
||||
import type { StackState } from "@/lib/constant";
|
||||
import { TECH_OPTIONS } from "@/lib/constant";
|
||||
import { CATEGORY_ORDER } from "@/lib/stack-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShareDialogProps {
|
||||
children: React.ReactNode;
|
||||
stackUrl: string;
|
||||
stackState: StackState;
|
||||
}
|
||||
|
||||
export function ShareDialog({
|
||||
children,
|
||||
stackUrl,
|
||||
stackState,
|
||||
}: ShareDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const techBadges = (() => {
|
||||
const badges: React.ReactNode[] = [];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const categoryKey = category as keyof StackState;
|
||||
const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS];
|
||||
const selectedValue = stackState[categoryKey];
|
||||
|
||||
if (!options) continue;
|
||||
|
||||
if (Array.isArray(selectedValue)) {
|
||||
if (
|
||||
selectedValue.length === 0 ||
|
||||
(selectedValue.length === 1 && selectedValue[0] === "none")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of selectedValue) {
|
||||
if (id === "none") continue;
|
||||
const tech = options.find((opt) => opt.id === id);
|
||||
if (tech) {
|
||||
badges.push(
|
||||
<TechBadge
|
||||
key={`${category}-${tech.id}`}
|
||||
icon={tech.icon}
|
||||
name={tech.name}
|
||||
category={category}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tech = options.find((opt) => opt.id === selectedValue);
|
||||
if (
|
||||
!tech ||
|
||||
tech.id === "none" ||
|
||||
tech.id === "false" ||
|
||||
((category === "git" ||
|
||||
category === "install" ||
|
||||
category === "auth") &&
|
||||
tech.id === "true")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
badges.push(
|
||||
<TechBadge
|
||||
key={`${category}-${tech.id}`}
|
||||
icon={tech.icon}
|
||||
name={tech.name}
|
||||
category={category}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return badges;
|
||||
})();
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stackUrl);
|
||||
setCopied(true);
|
||||
toast.success("Link copied to clipboard!");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Failed to copy link");
|
||||
}
|
||||
};
|
||||
|
||||
const shareToTwitter = () => {
|
||||
const text = encodeURIComponent(
|
||||
`Check out this cool tech stack I configured with Create Better T Stack!\n\n🚀 ${techBadges.length} technologies selected\n\n`,
|
||||
);
|
||||
const url = encodeURIComponent(stackUrl);
|
||||
window.open(
|
||||
`https://twitter.com/intent/tweet?text=${text}&url=${url}`,
|
||||
"_blank",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Share2 className="h-5 w-5" />
|
||||
Share Your Stack
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share your custom tech stack configuration with others
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium text-foreground text-sm">
|
||||
Technologies
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 rounded border border-border bg-muted/20 p-3">
|
||||
{techBadges.length > 0 ? (
|
||||
techBadges
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
No technologies selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium text-foreground text-sm">QR Code</div>
|
||||
<div className="flex items-center justify-center rounded border border-border bg-muted/20 p-4">
|
||||
<div className="h-32 w-32">
|
||||
<QRCode data={stackUrl} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
Scan to view this tech stack
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium text-foreground text-sm">Share</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={shareToTwitter}
|
||||
className="flex-1"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />X (Twitter)
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={copyToClipboard}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
copied &&
|
||||
"border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy URL"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
109
apps/web/src/components/ui/tech-badge.tsx
Normal file
109
apps/web/src/components/ui/tech-badge.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TechBadgeProps {
|
||||
icon: string;
|
||||
name: string;
|
||||
category: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getBadgeColors = (category: string): string => {
|
||||
switch (category) {
|
||||
case "webFrontend":
|
||||
case "nativeFrontend":
|
||||
return "border-blue-300 bg-blue-100 text-blue-800 dark:border-blue-700/30 dark:bg-blue-900/30 dark:text-blue-300";
|
||||
case "runtime":
|
||||
return "border-amber-300 bg-amber-100 text-amber-800 dark:border-amber-700/30 dark:bg-amber-900/30 dark:text-amber-300";
|
||||
case "backend":
|
||||
return "border-sky-300 bg-sky-100 text-sky-800 dark:border-sky-700/30 dark:bg-sky-900/30 dark:text-sky-300";
|
||||
case "api":
|
||||
return "border-indigo-300 bg-indigo-100 text-indigo-800 dark:border-indigo-700/30 dark:bg-indigo-900/30 dark:text-indigo-300";
|
||||
case "database":
|
||||
return "border-emerald-300 bg-emerald-100 text-emerald-800 dark:border-emerald-700/30 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
case "orm":
|
||||
return "border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700/30 dark:bg-cyan-900/30 dark:text-cyan-300";
|
||||
case "auth":
|
||||
return "border-green-300 bg-green-100 text-green-800 dark:border-green-700/30 dark:bg-green-900/30 dark:text-green-300";
|
||||
case "dbSetup":
|
||||
return "border-pink-300 bg-pink-100 text-pink-800 dark:border-pink-700/30 dark:bg-pink-900/30 dark:text-pink-300";
|
||||
case "addons":
|
||||
return "border-violet-300 bg-violet-100 text-violet-800 dark:border-violet-700/30 dark:bg-violet-900/30 dark:text-violet-300";
|
||||
case "examples":
|
||||
return "border-teal-300 bg-teal-100 text-teal-800 dark:border-teal-700/30 dark:bg-teal-900/30 dark:text-teal-300";
|
||||
case "packageManager":
|
||||
return "border-orange-300 bg-orange-100 text-orange-800 dark:border-orange-700/30 dark:bg-orange-900/30 dark:text-orange-300";
|
||||
case "git":
|
||||
case "webDeploy":
|
||||
case "serverDeploy":
|
||||
case "install":
|
||||
return "border-gray-300 bg-gray-100 text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400";
|
||||
default:
|
||||
return "border-gray-300 bg-gray-100 text-gray-800 dark:border-gray-700/30 dark:bg-gray-900/30 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
function TechIcon({
|
||||
icon,
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
icon: string;
|
||||
name: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!icon) return null;
|
||||
|
||||
if (!icon.startsWith("https://")) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center text-lg", className)}>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let iconSrc = icon;
|
||||
if (
|
||||
theme === "light" &&
|
||||
(icon.includes("drizzle") ||
|
||||
icon.includes("prisma") ||
|
||||
icon.includes("express") ||
|
||||
icon.includes("clerk"))
|
||||
) {
|
||||
iconSrc = icon.replace(".svg", "-light.svg");
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
suppressHydrationWarning
|
||||
src={iconSrc}
|
||||
alt={`${name} icon`}
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn("inline-block", className)}
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TechBadge({ icon, name, category, className }: TechBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs",
|
||||
getBadgeColors(category),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon !== "" && (
|
||||
<TechIcon icon={icon} name={name} className={cn("h-3 w-3")} />
|
||||
)}
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/lib/stack-server.ts
Normal file
65
apps/web/src/lib/stack-server.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
createLoader,
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
} from "nuqs/server";
|
||||
import { DEFAULT_STACK, type StackState, TECH_OPTIONS } from "@/lib/constant";
|
||||
|
||||
const getValidIds = (category: keyof typeof TECH_OPTIONS): string[] => {
|
||||
return TECH_OPTIONS[category]?.map((opt) => opt.id) ?? [];
|
||||
};
|
||||
|
||||
// Server-side parsers (same as client-side but imported from nuqs/server)
|
||||
const serverStackParsers = {
|
||||
projectName: parseAsString.withDefault(DEFAULT_STACK.projectName),
|
||||
webFrontend: parseAsArrayOf(parseAsString).withDefault(
|
||||
DEFAULT_STACK.webFrontend,
|
||||
),
|
||||
nativeFrontend: parseAsArrayOf(parseAsString).withDefault(
|
||||
DEFAULT_STACK.nativeFrontend,
|
||||
),
|
||||
runtime: parseAsStringEnum<StackState["runtime"]>(
|
||||
getValidIds("runtime"),
|
||||
).withDefault(DEFAULT_STACK.runtime),
|
||||
backend: parseAsStringEnum<StackState["backend"]>(
|
||||
getValidIds("backend"),
|
||||
).withDefault(DEFAULT_STACK.backend),
|
||||
api: parseAsStringEnum<StackState["api"]>(getValidIds("api")).withDefault(
|
||||
DEFAULT_STACK.api,
|
||||
),
|
||||
database: parseAsStringEnum<StackState["database"]>(
|
||||
getValidIds("database"),
|
||||
).withDefault(DEFAULT_STACK.database),
|
||||
orm: parseAsStringEnum<StackState["orm"]>(getValidIds("orm")).withDefault(
|
||||
DEFAULT_STACK.orm,
|
||||
),
|
||||
dbSetup: parseAsStringEnum<StackState["dbSetup"]>(
|
||||
getValidIds("dbSetup"),
|
||||
).withDefault(DEFAULT_STACK.dbSetup),
|
||||
auth: parseAsStringEnum<StackState["auth"]>(getValidIds("auth")).withDefault(
|
||||
DEFAULT_STACK.auth,
|
||||
),
|
||||
packageManager: parseAsStringEnum<StackState["packageManager"]>(
|
||||
getValidIds("packageManager"),
|
||||
).withDefault(DEFAULT_STACK.packageManager),
|
||||
addons: parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.addons),
|
||||
examples: parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.examples),
|
||||
git: parseAsStringEnum<StackState["git"]>(["true", "false"]).withDefault(
|
||||
DEFAULT_STACK.git,
|
||||
),
|
||||
install: parseAsStringEnum<StackState["install"]>([
|
||||
"true",
|
||||
"false",
|
||||
]).withDefault(DEFAULT_STACK.install),
|
||||
webDeploy: parseAsStringEnum<StackState["webDeploy"]>(
|
||||
getValidIds("webDeploy"),
|
||||
).withDefault(DEFAULT_STACK.webDeploy),
|
||||
serverDeploy: parseAsStringEnum<StackState["serverDeploy"]>(
|
||||
getValidIds("serverDeploy"),
|
||||
).withDefault(DEFAULT_STACK.serverDeploy),
|
||||
};
|
||||
|
||||
export const loadStackParams = createLoader(serverStackParsers);
|
||||
|
||||
export type LoadedStackState = Awaited<ReturnType<typeof loadStackParams>>;
|
||||
@@ -83,4 +83,5 @@ export const stackQueryStatesOptions = {
|
||||
history: "replace" as const,
|
||||
shallow: false,
|
||||
urlKeys: stackUrlKeys,
|
||||
clearOnDefault: true,
|
||||
};
|
||||
|
||||
467
apps/web/src/lib/stack-utils.ts
Normal file
467
apps/web/src/lib/stack-utils.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
} from "nuqs";
|
||||
import {
|
||||
DEFAULT_STACK,
|
||||
isStackDefault,
|
||||
type StackState,
|
||||
TECH_OPTIONS,
|
||||
} from "@/lib/constant";
|
||||
import {
|
||||
stackParsers,
|
||||
stackQueryStatesOptions,
|
||||
stackUrlKeys,
|
||||
} from "@/lib/stack-url-state";
|
||||
|
||||
const getValidIds = (category: keyof typeof TECH_OPTIONS): string[] => {
|
||||
return TECH_OPTIONS[category]?.map((opt) => opt.id) ?? [];
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [
|
||||
"webFrontend",
|
||||
"nativeFrontend",
|
||||
"backend",
|
||||
"runtime",
|
||||
"api",
|
||||
"database",
|
||||
"orm",
|
||||
"dbSetup",
|
||||
"webDeploy",
|
||||
"serverDeploy",
|
||||
"auth",
|
||||
"packageManager",
|
||||
"addons",
|
||||
"examples",
|
||||
"git",
|
||||
"install",
|
||||
];
|
||||
|
||||
function getStackKeyFromUrlKey(urlKey: string): keyof StackState | null {
|
||||
for (const [stackKey, urlKeyValue] of Object.entries(stackUrlKeys)) {
|
||||
if (urlKeyValue === urlKey) {
|
||||
return stackKey as keyof StackState;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSearchParamsToStack(searchParams: {
|
||||
[key: string]: string | string[] | undefined;
|
||||
}): StackState {
|
||||
const parsedStack: StackState = { ...DEFAULT_STACK };
|
||||
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
if (
|
||||
key === "utm_source" ||
|
||||
key === "utm_medium" ||
|
||||
key === "utm_campaign"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stackKey = getStackKeyFromUrlKey(key);
|
||||
if (stackKey && value !== undefined) {
|
||||
try {
|
||||
const parser = stackParsers[stackKey];
|
||||
if (parser) {
|
||||
const parsedValue = parser.parseServerSide(
|
||||
Array.isArray(value) ? value[0] : value,
|
||||
);
|
||||
(parsedStack as Record<string, unknown>)[stackKey] = parsedValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse ${key}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(DEFAULT_STACK)) {
|
||||
if (parsedStack[key as keyof StackState] === undefined) {
|
||||
(parsedStack as Record<string, unknown>)[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedStack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable summary of the stack
|
||||
*/
|
||||
export function generateStackSummary(stack: StackState): string {
|
||||
const selectedTechs: string[] = [];
|
||||
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const categoryKey = category as keyof StackState;
|
||||
const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS];
|
||||
const selectedValue = stack[categoryKey];
|
||||
|
||||
if (!options) continue;
|
||||
|
||||
if (Array.isArray(selectedValue)) {
|
||||
if (
|
||||
selectedValue.length === 0 ||
|
||||
(selectedValue.length === 1 && selectedValue[0] === "none")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of selectedValue) {
|
||||
if (id === "none") continue;
|
||||
const tech = options.find((opt) => opt.id === id);
|
||||
if (tech) {
|
||||
selectedTechs.push(tech.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tech = options.find((opt) => opt.id === selectedValue);
|
||||
if (
|
||||
!tech ||
|
||||
tech.id === "none" ||
|
||||
tech.id === "false" ||
|
||||
((category === "git" ||
|
||||
category === "install" ||
|
||||
category === "auth") &&
|
||||
tech.id === "true")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
selectedTechs.push(tech.name);
|
||||
}
|
||||
}
|
||||
|
||||
return selectedTechs.length > 0 ? selectedTechs.join(" • ") : "Custom stack";
|
||||
}
|
||||
|
||||
export function generateStackCommand(stack: StackState): string {
|
||||
let base: string;
|
||||
switch (stack.packageManager) {
|
||||
case "npm":
|
||||
base = "npx create-better-t-stack@latest";
|
||||
break;
|
||||
case "pnpm":
|
||||
base = "pnpm create better-t-stack@latest";
|
||||
break;
|
||||
default:
|
||||
base = "bun create better-t-stack@latest";
|
||||
break;
|
||||
}
|
||||
|
||||
const projectName = stack.projectName || "my-better-t-app";
|
||||
const flags: string[] = [];
|
||||
|
||||
const isDefaultStack = Object.keys(DEFAULT_STACK).every((key) => {
|
||||
if (key === "projectName") return true;
|
||||
const defaultKey = key as keyof StackState;
|
||||
return isStackDefault(stack, defaultKey, stack[defaultKey]);
|
||||
});
|
||||
|
||||
if (isDefaultStack) {
|
||||
flags.push("--yes");
|
||||
} else {
|
||||
const combinedFrontends = [
|
||||
...stack.webFrontend,
|
||||
...stack.nativeFrontend,
|
||||
].filter((v, _, arr) => v !== "none" || arr.length === 1);
|
||||
|
||||
if (combinedFrontends.length === 0 || combinedFrontends[0] === "none") {
|
||||
flags.push("--frontend none");
|
||||
} else {
|
||||
flags.push(`--frontend ${combinedFrontends.join(" ")}`);
|
||||
}
|
||||
|
||||
flags.push(`--backend ${stack.backend}`);
|
||||
flags.push(`--runtime ${stack.runtime}`);
|
||||
flags.push(`--api ${stack.api}`);
|
||||
flags.push(`--auth ${stack.auth}`);
|
||||
flags.push(`--database ${stack.database}`);
|
||||
flags.push(`--orm ${stack.orm}`);
|
||||
flags.push(`--db-setup ${stack.dbSetup}`);
|
||||
flags.push(`--package-manager ${stack.packageManager}`);
|
||||
|
||||
if (stack.git === "false") {
|
||||
flags.push("--no-git");
|
||||
} else {
|
||||
flags.push("--git");
|
||||
}
|
||||
|
||||
flags.push(`--web-deploy ${stack.webDeploy}`);
|
||||
flags.push(`--server-deploy ${stack.serverDeploy}`);
|
||||
|
||||
if (stack.install === "false") {
|
||||
flags.push("--no-install");
|
||||
} else {
|
||||
flags.push("--install");
|
||||
}
|
||||
|
||||
if (stack.addons.length > 0) {
|
||||
const validAddons = stack.addons.filter((addon) =>
|
||||
[
|
||||
"pwa",
|
||||
"tauri",
|
||||
"starlight",
|
||||
"biome",
|
||||
"husky",
|
||||
"turborepo",
|
||||
"ultracite",
|
||||
"fumadocs",
|
||||
"oxlint",
|
||||
"ruler",
|
||||
].includes(addon),
|
||||
);
|
||||
if (validAddons.length > 0) {
|
||||
flags.push(`--addons ${validAddons.join(" ")}`);
|
||||
}
|
||||
} else {
|
||||
flags.push("--addons none");
|
||||
}
|
||||
|
||||
if (stack.examples.length > 0) {
|
||||
flags.push(`--examples ${stack.examples.join(" ")}`);
|
||||
} else {
|
||||
flags.push("--examples none");
|
||||
}
|
||||
}
|
||||
|
||||
return `${base} ${projectName}${
|
||||
flags.length > 0 ? ` ${flags.join(" ")}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate stack URL from pathname and search params
|
||||
*/
|
||||
export function generateStackUrl(
|
||||
pathname: string,
|
||||
searchParams: URLSearchParams,
|
||||
): string {
|
||||
const searchString = searchParams.toString();
|
||||
const relativeUrl = `${pathname}${searchString ? `?${searchString}` : ""}`;
|
||||
return `https://better-t-stack.dev${relativeUrl}`;
|
||||
}
|
||||
|
||||
export function generateStackUrlFromState(
|
||||
stack: StackState,
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
const origin =
|
||||
baseUrl ||
|
||||
(typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://better-t-stack.dev");
|
||||
|
||||
const isDefaultStack = Object.keys(DEFAULT_STACK).every((key) => {
|
||||
if (key === "projectName") return true;
|
||||
const defaultKey = key as keyof StackState;
|
||||
return isStackDefault(stack, defaultKey, stack[defaultKey]);
|
||||
});
|
||||
|
||||
if (isDefaultStack) {
|
||||
return `${origin}/stack`;
|
||||
}
|
||||
|
||||
const stackParams = new URLSearchParams();
|
||||
|
||||
for (const [stackKey, urlKey] of Object.entries(stackUrlKeys)) {
|
||||
const value = stack[stackKey as keyof StackState];
|
||||
if (value !== undefined) {
|
||||
if (Array.isArray(value)) {
|
||||
stackParams.set(urlKey, value.join(","));
|
||||
} else {
|
||||
stackParams.set(urlKey, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${origin}/stack?${stackParams.toString()}`;
|
||||
}
|
||||
|
||||
export function useStackStateWithAllParams() {
|
||||
const [stack, setStack] = useQueryStates(
|
||||
stackParsers,
|
||||
stackQueryStatesOptions,
|
||||
);
|
||||
|
||||
const setStackWithAllParams = async (
|
||||
newStack: Partial<StackState> | ((prev: StackState) => Partial<StackState>),
|
||||
) => {
|
||||
const updatedStack =
|
||||
typeof newStack === "function" ? newStack(stack) : newStack;
|
||||
const finalStack = { ...stack, ...updatedStack };
|
||||
|
||||
const isFinalStackDefault = Object.keys(DEFAULT_STACK).every((key) => {
|
||||
if (key === "projectName") return true;
|
||||
const defaultKey = key as keyof StackState;
|
||||
return isStackDefault(finalStack, defaultKey, finalStack[defaultKey]);
|
||||
});
|
||||
|
||||
if (isFinalStackDefault) {
|
||||
await setStack(null);
|
||||
} else {
|
||||
await setStack(finalStack);
|
||||
}
|
||||
};
|
||||
|
||||
return [stack, setStackWithAllParams] as const;
|
||||
}
|
||||
|
||||
export function useIndividualStackStates() {
|
||||
const [projectName, setProjectName] = useQueryState(
|
||||
"name",
|
||||
parseAsString.withDefault(DEFAULT_STACK.projectName),
|
||||
);
|
||||
|
||||
const [webFrontend, setWebFrontend] = useQueryState(
|
||||
"fe-w",
|
||||
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.webFrontend),
|
||||
);
|
||||
|
||||
const [nativeFrontend, setNativeFrontend] = useQueryState(
|
||||
"fe-n",
|
||||
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.nativeFrontend),
|
||||
);
|
||||
|
||||
const [runtime, setRuntime] = useQueryState(
|
||||
"rt",
|
||||
parseAsStringEnum(getValidIds("runtime")).withDefault(
|
||||
DEFAULT_STACK.runtime,
|
||||
),
|
||||
);
|
||||
|
||||
const [backend, setBackend] = useQueryState(
|
||||
"be",
|
||||
parseAsStringEnum(getValidIds("backend")).withDefault(
|
||||
DEFAULT_STACK.backend,
|
||||
),
|
||||
);
|
||||
|
||||
const [api, setApi] = useQueryState(
|
||||
"api",
|
||||
parseAsStringEnum(getValidIds("api")).withDefault(DEFAULT_STACK.api),
|
||||
);
|
||||
|
||||
const [database, setDatabase] = useQueryState(
|
||||
"db",
|
||||
parseAsStringEnum(getValidIds("database")).withDefault(
|
||||
DEFAULT_STACK.database,
|
||||
),
|
||||
);
|
||||
|
||||
const [orm, setOrm] = useQueryState(
|
||||
"orm",
|
||||
parseAsStringEnum(getValidIds("orm")).withDefault(DEFAULT_STACK.orm),
|
||||
);
|
||||
|
||||
const [dbSetup, setDbSetup] = useQueryState(
|
||||
"dbs",
|
||||
parseAsStringEnum(getValidIds("dbSetup")).withDefault(
|
||||
DEFAULT_STACK.dbSetup,
|
||||
),
|
||||
);
|
||||
|
||||
const [auth, setAuth] = useQueryState(
|
||||
"au",
|
||||
parseAsStringEnum(getValidIds("auth")).withDefault(DEFAULT_STACK.auth),
|
||||
);
|
||||
|
||||
const [packageManager, setPackageManager] = useQueryState(
|
||||
"pm",
|
||||
parseAsStringEnum(getValidIds("packageManager")).withDefault(
|
||||
DEFAULT_STACK.packageManager,
|
||||
),
|
||||
);
|
||||
|
||||
const [addons, setAddons] = useQueryState(
|
||||
"add",
|
||||
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.addons),
|
||||
);
|
||||
|
||||
const [examples, setExamples] = useQueryState(
|
||||
"ex",
|
||||
parseAsArrayOf(parseAsString).withDefault(DEFAULT_STACK.examples),
|
||||
);
|
||||
|
||||
const [git, setGit] = useQueryState(
|
||||
"git",
|
||||
parseAsStringEnum(["true", "false"] as const).withDefault(
|
||||
DEFAULT_STACK.git as "true" | "false",
|
||||
),
|
||||
);
|
||||
|
||||
const [install, setInstall] = useQueryState(
|
||||
"i",
|
||||
parseAsStringEnum(["true", "false"] as const).withDefault(
|
||||
DEFAULT_STACK.install as "true" | "false",
|
||||
),
|
||||
);
|
||||
|
||||
const [webDeploy, setWebDeploy] = useQueryState(
|
||||
"wd",
|
||||
parseAsStringEnum(getValidIds("webDeploy")).withDefault(
|
||||
DEFAULT_STACK.webDeploy,
|
||||
),
|
||||
);
|
||||
|
||||
const [serverDeploy, setServerDeploy] = useQueryState(
|
||||
"sd",
|
||||
parseAsStringEnum(getValidIds("serverDeploy")).withDefault(
|
||||
DEFAULT_STACK.serverDeploy,
|
||||
),
|
||||
);
|
||||
|
||||
const stack: StackState = {
|
||||
projectName,
|
||||
webFrontend,
|
||||
nativeFrontend,
|
||||
runtime,
|
||||
backend,
|
||||
api,
|
||||
database,
|
||||
orm,
|
||||
dbSetup,
|
||||
auth,
|
||||
packageManager,
|
||||
addons,
|
||||
examples,
|
||||
git,
|
||||
install,
|
||||
webDeploy,
|
||||
serverDeploy,
|
||||
};
|
||||
|
||||
const setStack = async (updates: Partial<StackState>) => {
|
||||
const setters = {
|
||||
projectName: setProjectName,
|
||||
webFrontend: setWebFrontend,
|
||||
nativeFrontend: setNativeFrontend,
|
||||
runtime: setRuntime,
|
||||
backend: setBackend,
|
||||
api: setApi,
|
||||
database: setDatabase,
|
||||
orm: setOrm,
|
||||
dbSetup: setDbSetup,
|
||||
auth: setAuth,
|
||||
packageManager: setPackageManager,
|
||||
addons: setAddons,
|
||||
examples: setExamples,
|
||||
git: setGit,
|
||||
install: setInstall,
|
||||
webDeploy: setWebDeploy,
|
||||
serverDeploy: setServerDeploy,
|
||||
};
|
||||
|
||||
const promises = Object.entries(updates).map(([key, value]) => {
|
||||
const setter = setters[key as keyof typeof setters];
|
||||
return setter(value as never);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
return [stack, setStack] as const;
|
||||
}
|
||||
|
||||
export { CATEGORY_ORDER };
|
||||
Reference in New Issue
Block a user