mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat(cli): prisma + workers, prisma + turso, planetscale (postgres/mysql) support (#567)
This commit is contained in:
62
apps/web/src/app/(home)/new/_components/action-buttons.tsx
Normal file
62
apps/web/src/app/(home)/new/_components/action-buttons.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw, Settings, Shuffle, Star } from "lucide-react";
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onReset: () => void;
|
||||
onRandom: () => void;
|
||||
onSave: () => void;
|
||||
onLoad: () => void;
|
||||
hasSavedStack: boolean;
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
onReset,
|
||||
onRandom,
|
||||
onSave,
|
||||
onLoad,
|
||||
hasSavedStack,
|
||||
}: ActionButtonsProps) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Reset to defaults"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRandom}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Generate a random stack"
|
||||
>
|
||||
<Shuffle className="h-3 w-3" />
|
||||
Random
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Save current preferences"
|
||||
>
|
||||
<Star className="h-3 w-3" />
|
||||
Save
|
||||
</button>
|
||||
{hasSavedStack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoad}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Load saved preferences"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/app/(home)/new/_components/preset-dropdown.tsx
Normal file
43
apps/web/src/app/(home)/new/_components/preset-dropdown.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Zap } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PRESET_TEMPLATES } from "@/lib/constant";
|
||||
|
||||
interface PresetDropdownProps {
|
||||
onApplyPreset: (presetId: string) => void;
|
||||
}
|
||||
|
||||
export function PresetDropdown({ onApplyPreset }: PresetDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
Presets
|
||||
<ChevronDown className="ml-auto h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 bg-fd-background">
|
||||
{PRESET_TEMPLATES.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => onApplyPreset(preset.id)}
|
||||
className="flex flex-col items-start gap-1 p-3"
|
||||
>
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
<div className="text-xs">{preset.description}</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/(home)/new/_components/share-button.tsx
Normal file
25
apps/web/src/app/(home)/new/_components/share-button.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Share2 } from "lucide-react";
|
||||
import { ShareDialog } from "@/components/ui/share-dialog";
|
||||
import type { StackState } from "@/lib/constant";
|
||||
|
||||
interface ShareButtonProps {
|
||||
stackUrl: string;
|
||||
stackState: StackState;
|
||||
}
|
||||
|
||||
export function ShareButton({ stackUrl, stackState }: ShareButtonProps) {
|
||||
return (
|
||||
<ShareDialog stackUrl={stackUrl} stackState={stackState}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Share your stack"
|
||||
>
|
||||
<Share2 className="h-3 w-3" />
|
||||
Share
|
||||
</button>
|
||||
</ShareDialog>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,8 @@ import {
|
||||
ChevronDown,
|
||||
ClipboardCopy,
|
||||
InfoIcon,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Share2,
|
||||
Shuffle,
|
||||
Star,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import type React from "react";
|
||||
@@ -27,11 +22,9 @@ import { toast } from "sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ShareDialog } from "@/components/ui/share-dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -51,7 +44,10 @@ import {
|
||||
generateStackSharingUrl,
|
||||
} from "@/lib/stack-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ActionButtons } from "./action-buttons";
|
||||
import { getBadgeColors } from "./get-badge-color";
|
||||
import { PresetDropdown } from "./preset-dropdown";
|
||||
import { ShareButton } from "./share-button";
|
||||
import { TechIcon } from "./tech-icon";
|
||||
import {
|
||||
analyzeStackCompatibility,
|
||||
@@ -60,6 +56,7 @@ import {
|
||||
isOptionCompatible,
|
||||
validateProjectName,
|
||||
} from "./utils";
|
||||
import { YoloToggle } from "./yolo-toggle";
|
||||
|
||||
const StackBuilder = () => {
|
||||
const [stack, setStack] = useStackState();
|
||||
@@ -407,7 +404,12 @@ const StackBuilder = () => {
|
||||
|
||||
const applyPreset = (presetId: string) => {
|
||||
const preset = PRESET_TEMPLATES.find(
|
||||
(template) => template.id === presetId,
|
||||
(template: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
stack: StackState;
|
||||
}) => template.id === presetId,
|
||||
);
|
||||
if (preset) {
|
||||
startTransition(() => {
|
||||
@@ -505,92 +507,41 @@ const StackBuilder = () => {
|
||||
|
||||
<div className="mt-auto border-border border-t pt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetStack}
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Reset to defaults"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={getRandomStack}
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Generate a random stack"
|
||||
>
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
Random
|
||||
</button>
|
||||
</div>
|
||||
<ActionButtons
|
||||
onReset={resetStack}
|
||||
onRandom={getRandomStack}
|
||||
onSave={saveCurrentStack}
|
||||
onLoad={loadSavedStack}
|
||||
hasSavedStack={!!lastSavedStack}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveCurrentStack}
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Save current preferences"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Save
|
||||
</button>
|
||||
{lastSavedStack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSavedStack}
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Load saved preferences"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Load
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-9" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<ShareButton stackUrl={getStackUrl()} stackState={stack} />
|
||||
|
||||
<ShareDialog stackUrl={getStackUrl()} stackState={stack}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
title="Share your stack"
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
Share Stack
|
||||
</button>
|
||||
</ShareDialog>
|
||||
<PresetDropdown onApplyPreset={applyPreset} />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Quick Preset
|
||||
<ChevronDown className="ml-auto h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-64 bg-fd-background"
|
||||
>
|
||||
{PRESET_TEMPLATES.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => applyPreset(preset.id)}
|
||||
className="flex flex-col items-start gap-1 p-3"
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<div className="font-medium text-sm">
|
||||
{preset.name}
|
||||
</div>
|
||||
<div className="text-xs">{preset.description}</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Settings className="h-3 w-3" />
|
||||
Settings
|
||||
<ChevronDown className="ml-auto h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-64 bg-fd-background"
|
||||
>
|
||||
<YoloToggle
|
||||
stack={stack}
|
||||
onToggle={(yolo) => setStack({ yolo })}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,15 @@ interface CompatibilityResult {
|
||||
export const analyzeStackCompatibility = (
|
||||
stack: StackState,
|
||||
): CompatibilityResult => {
|
||||
// Skip all validation if YOLO mode is enabled
|
||||
if (stack.yolo === "true") {
|
||||
return {
|
||||
adjustedStack: null,
|
||||
notes: {},
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
|
||||
const nextStack = { ...stack };
|
||||
let changed = false;
|
||||
const notes: CompatibilityResult["notes"] = {};
|
||||
@@ -352,12 +361,12 @@ export const analyzeStackCompatibility = (
|
||||
"Database set to 'SQLite' (Turso hosting requires SQLite database)",
|
||||
});
|
||||
}
|
||||
if (nextStack.orm !== "drizzle") {
|
||||
if (nextStack.orm !== "drizzle" && nextStack.orm !== "prisma") {
|
||||
notes.dbSetup.notes.push(
|
||||
"Turso requires Drizzle ORM. It will be selected.",
|
||||
"Turso requires Drizzle or Prisma ORM. Drizzle will be selected.",
|
||||
);
|
||||
notes.orm.notes.push(
|
||||
"Turso DB setup requires Drizzle ORM. It will be selected.",
|
||||
"Turso DB setup requires Drizzle or Prisma ORM. Drizzle will be selected.",
|
||||
);
|
||||
notes.dbSetup.hasIssue = true;
|
||||
notes.orm.hasIssue = true;
|
||||
@@ -366,7 +375,7 @@ export const analyzeStackCompatibility = (
|
||||
changes.push({
|
||||
category: "dbSetup",
|
||||
message:
|
||||
"ORM set to 'Drizzle' (Turso hosting requires Drizzle ORM)",
|
||||
"ORM set to 'Drizzle' (Turso hosting requires Drizzle or Prisma ORM)",
|
||||
});
|
||||
}
|
||||
} else if (nextStack.dbSetup === "prisma-postgres") {
|
||||
@@ -454,6 +463,27 @@ export const analyzeStackCompatibility = (
|
||||
"Database set to 'PostgreSQL' (Supabase hosting requires PostgreSQL database)",
|
||||
});
|
||||
}
|
||||
} else if (nextStack.dbSetup === "planetscale") {
|
||||
if (
|
||||
nextStack.database !== "postgres" &&
|
||||
nextStack.database !== "mysql"
|
||||
) {
|
||||
notes.dbSetup.notes.push(
|
||||
"PlanetScale requires PostgreSQL or MySQL. PostgreSQL will be selected.",
|
||||
);
|
||||
notes.database.notes.push(
|
||||
"PlanetScale DB setup requires PostgreSQL or MySQL. PostgreSQL will be selected.",
|
||||
);
|
||||
notes.dbSetup.hasIssue = true;
|
||||
notes.database.hasIssue = true;
|
||||
nextStack.database = "postgres";
|
||||
changed = true;
|
||||
changes.push({
|
||||
category: "dbSetup",
|
||||
message:
|
||||
"Database set to 'PostgreSQL' (PlanetScale supports PostgreSQL and MySQL)",
|
||||
});
|
||||
}
|
||||
} else if (nextStack.dbSetup === "d1") {
|
||||
if (nextStack.database !== "sqlite") {
|
||||
notes.dbSetup.notes.push(
|
||||
@@ -471,6 +501,23 @@ export const analyzeStackCompatibility = (
|
||||
message: "Database set to 'SQLite' (required by Cloudflare D1)",
|
||||
});
|
||||
}
|
||||
if (nextStack.orm !== "drizzle" && nextStack.orm !== "prisma") {
|
||||
notes.dbSetup.notes.push(
|
||||
"Cloudflare D1 requires Drizzle or Prisma ORM. Drizzle will be selected.",
|
||||
);
|
||||
notes.orm.notes.push(
|
||||
"Cloudflare D1 DB setup requires Drizzle or Prisma ORM. Drizzle will be selected.",
|
||||
);
|
||||
notes.dbSetup.hasIssue = true;
|
||||
notes.orm.hasIssue = true;
|
||||
nextStack.orm = "drizzle";
|
||||
changed = true;
|
||||
changes.push({
|
||||
category: "dbSetup",
|
||||
message:
|
||||
"ORM set to 'Drizzle' (Cloudflare D1 requires Drizzle or Prisma ORM)",
|
||||
});
|
||||
}
|
||||
if (nextStack.runtime !== "workers") {
|
||||
notes.dbSetup.notes.push(
|
||||
"Cloudflare D1 requires Cloudflare Workers runtime. It will be selected.",
|
||||
@@ -487,22 +534,6 @@ export const analyzeStackCompatibility = (
|
||||
message: "Runtime set to 'Cloudflare Workers' (required by D1)",
|
||||
});
|
||||
}
|
||||
if (nextStack.orm !== "drizzle") {
|
||||
notes.dbSetup.notes.push(
|
||||
"Cloudflare D1 requires Drizzle ORM. It will be selected.",
|
||||
);
|
||||
notes.orm.notes.push(
|
||||
"Cloudflare D1 DB setup requires Drizzle ORM. It will be selected.",
|
||||
);
|
||||
notes.dbSetup.hasIssue = true;
|
||||
notes.orm.hasIssue = true;
|
||||
nextStack.orm = "drizzle";
|
||||
changed = true;
|
||||
changes.push({
|
||||
category: "dbSetup",
|
||||
message: "ORM set to 'Drizzle' (required by Cloudflare D1)",
|
||||
});
|
||||
}
|
||||
if (nextStack.backend !== "hono") {
|
||||
notes.dbSetup.notes.push(
|
||||
"Cloudflare D1 requires Hono backend. It will be selected.",
|
||||
@@ -581,6 +612,9 @@ export const analyzeStackCompatibility = (
|
||||
} else if (nextStack.dbSetup === "mongodb-atlas") {
|
||||
selectedDatabase = "mongodb";
|
||||
databaseName = "MongoDB";
|
||||
} else if (nextStack.dbSetup === "planetscale") {
|
||||
selectedDatabase = "postgres";
|
||||
databaseName = "PostgreSQL";
|
||||
}
|
||||
|
||||
notes.dbSetup.notes.push(
|
||||
@@ -618,24 +652,6 @@ export const analyzeStackCompatibility = (
|
||||
});
|
||||
}
|
||||
|
||||
if (nextStack.orm !== "drizzle" && nextStack.orm !== "none") {
|
||||
notes.runtime.notes.push(
|
||||
"Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.",
|
||||
);
|
||||
notes.orm.notes.push(
|
||||
"Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.",
|
||||
);
|
||||
notes.runtime.hasIssue = true;
|
||||
notes.orm.hasIssue = true;
|
||||
nextStack.orm = "drizzle";
|
||||
changed = true;
|
||||
changes.push({
|
||||
category: "runtime",
|
||||
message:
|
||||
"ORM set to 'Drizzle' (Cloudflare Workers runtime only supports Drizzle or no ORM)",
|
||||
});
|
||||
}
|
||||
|
||||
if (nextStack.database === "mongodb") {
|
||||
notes.runtime.notes.push(
|
||||
"Cloudflare Workers runtime is not compatible with MongoDB. SQLite will be selected.",
|
||||
@@ -1050,49 +1066,6 @@ export const analyzeStackCompatibility = (
|
||||
});
|
||||
}
|
||||
|
||||
const isAlchemyWebDeploy = nextStack.webDeploy === "alchemy";
|
||||
const isAlchemyServerDeploy = nextStack.serverDeploy === "alchemy";
|
||||
|
||||
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
|
||||
const incompatibleFrontends = nextStack.webFrontend.filter(
|
||||
(f) => f === "next",
|
||||
);
|
||||
|
||||
if (incompatibleFrontends.length > 0) {
|
||||
const deployType =
|
||||
isAlchemyWebDeploy && isAlchemyServerDeploy
|
||||
? "web and server deployment"
|
||||
: isAlchemyWebDeploy
|
||||
? "web deployment"
|
||||
: "server deployment";
|
||||
|
||||
notes.webFrontend.notes.push(
|
||||
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}. These frontends will be removed.`,
|
||||
);
|
||||
notes.webDeploy.notes.push(
|
||||
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`,
|
||||
);
|
||||
notes.serverDeploy.notes.push(
|
||||
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`,
|
||||
);
|
||||
notes.webFrontend.hasIssue = true;
|
||||
notes.webDeploy.hasIssue = true;
|
||||
notes.serverDeploy.hasIssue = true;
|
||||
|
||||
nextStack.webFrontend = nextStack.webFrontend.filter((f) => f !== "next");
|
||||
|
||||
if (nextStack.webFrontend.length === 0) {
|
||||
nextStack.webFrontend = ["tanstack-router"];
|
||||
}
|
||||
|
||||
changed = true;
|
||||
changes.push({
|
||||
category: "alchemy",
|
||||
message: `Removed ${incompatibleFrontends.join(" and ")} frontend (temporarily not compatible with Alchemy ${deployType} - support coming soon)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
nextStack.serverDeploy === "alchemy" &&
|
||||
(nextStack.runtime !== "workers" || nextStack.backend !== "hono")
|
||||
@@ -1223,15 +1196,6 @@ export const getDisabledReason = (
|
||||
const { adjustedStack } = analyzeStackCompatibility(simulatedStack);
|
||||
const finalStack = adjustedStack ?? simulatedStack;
|
||||
|
||||
if (category === "webFrontend" && optionId === "next") {
|
||||
const isAlchemyWebDeploy = finalStack.webDeploy === "alchemy";
|
||||
const isAlchemyServerDeploy = finalStack.serverDeploy === "alchemy";
|
||||
|
||||
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
|
||||
return "Next.js is temporarily not compatible with Alchemy deployment. Support coming soon!";
|
||||
}
|
||||
}
|
||||
|
||||
if (category === "webFrontend" && optionId === "solid") {
|
||||
if (finalStack.backend === "convex") {
|
||||
return "Solid is not compatible with Convex backend. Try TanStack Router, React Router, or Next.js instead.";
|
||||
@@ -1383,11 +1347,17 @@ export const getDisabledReason = (
|
||||
if (finalStack.database === "none") {
|
||||
return "Prisma ORM requires a database. Select a database first (SQLite, PostgreSQL, MySQL, or MongoDB).";
|
||||
}
|
||||
if (finalStack.dbSetup === "turso" && finalStack.database !== "sqlite") {
|
||||
return "Turso setup requires SQLite database. Select SQLite first.";
|
||||
}
|
||||
if (finalStack.dbSetup === "d1" && finalStack.database !== "sqlite") {
|
||||
return "Cloudflare D1 setup requires SQLite database. Select SQLite first.";
|
||||
}
|
||||
}
|
||||
|
||||
if (category === "dbSetup" && optionId === "turso") {
|
||||
if (finalStack.orm !== "drizzle") {
|
||||
return "Turso requires Drizzle ORM. Select Drizzle first.";
|
||||
if (finalStack.orm !== "drizzle" && finalStack.orm !== "prisma") {
|
||||
return "Turso requires Drizzle or Prisma ORM. Select Drizzle or Prisma first.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1398,8 +1368,8 @@ export const getDisabledReason = (
|
||||
}
|
||||
|
||||
if (category === "dbSetup" && optionId === "d1") {
|
||||
if (finalStack.orm !== "drizzle") {
|
||||
return "Cloudflare D1 requires Drizzle ORM. Select Drizzle first.";
|
||||
if (finalStack.orm !== "drizzle" && finalStack.orm !== "prisma") {
|
||||
return "Cloudflare D1 requires Drizzle or Prisma ORM. Select Drizzle or Prisma first.";
|
||||
}
|
||||
if (finalStack.runtime !== "workers") {
|
||||
return "Cloudflare D1 requires Cloudflare Workers runtime. Select Workers runtime first.";
|
||||
@@ -1461,15 +1431,20 @@ export const getDisabledReason = (
|
||||
finalStack.dbSetup !== "docker" &&
|
||||
finalStack.dbSetup !== "prisma-postgres" &&
|
||||
finalStack.dbSetup !== "neon" &&
|
||||
finalStack.dbSetup !== "supabase"
|
||||
finalStack.dbSetup !== "supabase" &&
|
||||
finalStack.dbSetup !== "planetscale"
|
||||
) {
|
||||
return "PostgreSQL database only works with Docker, Prisma PostgreSQL, Neon, Supabase, or Basic Setup. Select one of these options or change database.";
|
||||
return "PostgreSQL database only works with Docker, Prisma PostgreSQL, Neon, Supabase, PlanetScale, or Basic Setup. Select one of these options or change database.";
|
||||
}
|
||||
}
|
||||
|
||||
if (category === "database" && optionId === "mysql") {
|
||||
if (finalStack.dbSetup !== "none" && finalStack.dbSetup !== "docker") {
|
||||
return "MySQL database only works with Docker or Basic Setup. Select one of these options or change database.";
|
||||
if (
|
||||
finalStack.dbSetup !== "none" &&
|
||||
finalStack.dbSetup !== "docker" &&
|
||||
finalStack.dbSetup !== "planetscale"
|
||||
) {
|
||||
return "MySQL database only works with Docker, PlanetScale, or Basic Setup. Select one of these options or change database.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1584,12 +1559,27 @@ export const getDisabledReason = (
|
||||
}
|
||||
}
|
||||
|
||||
if (category === "dbSetup" && optionId === "planetscale") {
|
||||
if (finalStack.database !== "postgres" && finalStack.database !== "mysql") {
|
||||
return "PlanetScale requires PostgreSQL or MySQL database. Select PostgreSQL or MySQL first.";
|
||||
}
|
||||
}
|
||||
|
||||
if (category === "dbSetup" && optionId === "supabase") {
|
||||
if ((finalStack.database as string) !== "postgres") {
|
||||
return "Supabase requires PostgreSQL database. Select PostgreSQL first.";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
category === "database" &&
|
||||
(finalStack.dbSetup as string) === "planetscale"
|
||||
) {
|
||||
if (optionId !== "postgres" && optionId !== "mysql") {
|
||||
return "Selected DB Setup 'PlanetScale' requires PostgreSQL or MySQL. Select PostgreSQL or MySQL, or change DB Setup.";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
category === "database" &&
|
||||
(finalStack.dbSetup as string) === "supabase"
|
||||
@@ -1607,5 +1597,8 @@ export const isOptionCompatible = (
|
||||
category: keyof typeof TECH_OPTIONS,
|
||||
optionId: string,
|
||||
): boolean => {
|
||||
if (currentStack.yolo === "true") {
|
||||
return true;
|
||||
}
|
||||
return getDisabledReason(currentStack, category, optionId) === null;
|
||||
};
|
||||
|
||||
49
apps/web/src/app/(home)/new/_components/yolo-toggle.tsx
Normal file
49
apps/web/src/app/(home)/new/_components/yolo-toggle.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { StackState } from "@/lib/constant";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface YoloToggleProps {
|
||||
stack: StackState;
|
||||
onToggle: (yolo: string) => void;
|
||||
}
|
||||
|
||||
export function YoloToggle({ stack, onToggle }: YoloToggleProps) {
|
||||
const isYoloEnabled = stack.yolo === "true";
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex w-full items-center gap-3 p-3">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="flex flex-1 flex-col items-start">
|
||||
<div className="font-medium text-sm">YOLO Mode</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{isYoloEnabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isYoloEnabled}
|
||||
onCheckedChange={(checked) => onToggle(checked ? "true" : "false")}
|
||||
className={cn(
|
||||
isYoloEnabled && "data-[state=checked]:bg-destructive",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Disables all validation and adds --yolo flag to the command. Use at
|
||||
your own risk!
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,6 @@ export function ShareDialog({
|
||||
);
|
||||
};
|
||||
|
||||
// Generate QR code using local qrcode library
|
||||
useEffect(() => {
|
||||
const generateQRCode = async () => {
|
||||
try {
|
||||
@@ -264,25 +263,6 @@ export function ShareDialog({
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
47
apps/web/src/components/ui/toggle.tsx
Normal file
47
apps/web/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -328,6 +328,13 @@ export const TECH_OPTIONS: Record<
|
||||
icon: `${ICON_BASE_URL}/supabase.svg`,
|
||||
color: "from-emerald-400 to-emerald-600",
|
||||
},
|
||||
{
|
||||
id: "planetscale",
|
||||
name: "PlanetScale",
|
||||
description: "Serverless MySQL platform with branching",
|
||||
icon: `${ICON_BASE_URL}/planetscale.svg`,
|
||||
color: "from-orange-400 to-orange-600",
|
||||
},
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
@@ -604,6 +611,7 @@ export const PRESET_TEMPLATES = [
|
||||
api: "trpc",
|
||||
webDeploy: "none",
|
||||
serverDeploy: "none",
|
||||
yolo: "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -628,6 +636,7 @@ export const PRESET_TEMPLATES = [
|
||||
api: "none",
|
||||
webDeploy: "none",
|
||||
serverDeploy: "none",
|
||||
yolo: "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -652,6 +661,7 @@ export const PRESET_TEMPLATES = [
|
||||
api: "trpc",
|
||||
webDeploy: "none",
|
||||
serverDeploy: "none",
|
||||
yolo: "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -676,6 +686,7 @@ export const PRESET_TEMPLATES = [
|
||||
api: "trpc",
|
||||
webDeploy: "none",
|
||||
serverDeploy: "none",
|
||||
yolo: "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -700,6 +711,7 @@ export const PRESET_TEMPLATES = [
|
||||
api: "trpc",
|
||||
webDeploy: "alchemy",
|
||||
serverDeploy: "alchemy",
|
||||
yolo: "false",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -722,6 +734,7 @@ export type StackState = {
|
||||
api: string;
|
||||
webDeploy: string;
|
||||
serverDeploy: string;
|
||||
yolo: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_STACK: StackState = {
|
||||
@@ -742,6 +755,7 @@ export const DEFAULT_STACK: StackState = {
|
||||
api: "trpc",
|
||||
webDeploy: "none",
|
||||
serverDeploy: "none",
|
||||
yolo: "false",
|
||||
};
|
||||
|
||||
export const isStackDefault = <K extends keyof StackState>(
|
||||
|
||||
@@ -19,4 +19,5 @@ export const stackUrlKeys: UrlKeys<Record<keyof StackState, unknown>> = {
|
||||
install: "i",
|
||||
webDeploy: "wd",
|
||||
serverDeploy: "sd",
|
||||
yolo: "yolo",
|
||||
};
|
||||
|
||||
@@ -61,6 +61,9 @@ export const stackParsers = {
|
||||
serverDeploy: parseAsStringEnum<StackState["serverDeploy"]>(
|
||||
getValidIds("serverDeploy"),
|
||||
).withDefault(DEFAULT_STACK.serverDeploy),
|
||||
yolo: parseAsStringEnum<StackState["yolo"]>(["true", "false"]).withDefault(
|
||||
DEFAULT_STACK.yolo,
|
||||
),
|
||||
};
|
||||
|
||||
export const stackQueryStatesOptions = {
|
||||
|
||||
@@ -67,6 +67,10 @@ const serverStackParsers = {
|
||||
serverDeploy: parseAsStringEnumServer<StackState["serverDeploy"]>(
|
||||
getValidIds("serverDeploy"),
|
||||
).withDefault(DEFAULT_STACK.serverDeploy),
|
||||
yolo: parseAsStringEnumServer<StackState["yolo"]>([
|
||||
"true",
|
||||
"false",
|
||||
]).withDefault(DEFAULT_STACK.yolo),
|
||||
};
|
||||
|
||||
export const loadStackParams = createLoader(serverStackParsers, {
|
||||
|
||||
@@ -119,6 +119,10 @@ export function generateStackCommand(stack: StackState): string {
|
||||
`--examples ${stack.examples.join(" ") || "none"}`,
|
||||
];
|
||||
|
||||
if (stack.yolo === "true") {
|
||||
flags.push("--yolo");
|
||||
}
|
||||
|
||||
return `${base} ${projectName} ${flags.join(" ")}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user