From 089180e4005d75d25774993de5e1e2bd6f6b04f6 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Sat, 26 Apr 2025 21:37:07 +0530 Subject: [PATCH] update landing page and fix stack builder logic --- .../app/(home)/_components/CodeContainer.tsx | 415 ++++--- .../_components/CustomizableSection.tsx | 5 +- .../web/src/app/(home)/_components/Navbar.tsx | 478 ++++---- .../(home)/_components/SponsorsSection.tsx | 59 + .../app/(home)/_components/StackArchitech.tsx | 1061 +++++++++-------- .../app/(home)/_components/Testimonials.tsx | 323 ++--- apps/web/src/app/(home)/page.tsx | 16 +- apps/web/src/app/global.css | 2 +- bun.lock | 18 +- package.json | 2 +- 10 files changed, 1234 insertions(+), 1145 deletions(-) create mode 100644 apps/web/src/app/(home)/_components/SponsorsSection.tsx diff --git a/apps/web/src/app/(home)/_components/CodeContainer.tsx b/apps/web/src/app/(home)/_components/CodeContainer.tsx index 5209b5f..ff2d7bf 100644 --- a/apps/web/src/app/(home)/_components/CodeContainer.tsx +++ b/apps/web/src/app/(home)/_components/CodeContainer.tsx @@ -1,37 +1,32 @@ "use client"; import { cn } from "@/lib/utils"; -import { Check, ClipboardCopy, Terminal } from "lucide-react"; -import { motion } from "motion/react"; -import { useEffect, useRef, useState } from "react"; +import { Check, ClipboardCopy } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useState } from "react"; +import PackageIcon from "./Icons"; const CodeContainer = () => { - const [isOpen, setIsOpen] = useState(false); const [selectedPM, setSelectedPM] = useState<"npm" | "pnpm" | "bun">("bun"); const [copied, setCopied] = useState(false); - const menuRef = useRef(null); - const [showCursor, setShowCursor] = useState(true); + const [, setShowCursor] = useState(true); const [step, setStep] = useState(0); - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) - setIsOpen(false); - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - useEffect(() => { const interval = setInterval(() => setShowCursor((p) => !p), 500); return () => clearInterval(interval); }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { - if (step < 5) { - const timer = setTimeout( - () => setStep((s) => s + 1), - step === 0 ? 1000 : 400, - ); + setStep(0); + const initialTimer = setTimeout(() => setStep(1), 1000); + + return () => clearTimeout(initialTimer); + }, [selectedPM]); + + useEffect(() => { + if (step > 0 && step < 5) { + const timer = setTimeout(() => setStep((s) => s + 1), 400); return () => clearTimeout(timer); } }, [step]); @@ -42,181 +37,241 @@ const CodeContainer = () => { bun: "bun create better-t-stack@latest", }; - const copyToClipboard = async (pm: "npm" | "pnpm" | "bun") => { - await navigator.clipboard.writeText(commands[pm]); - setSelectedPM(pm); + const runCommands = { + npm: "npm run dev", + pnpm: "pnpm dev", + bun: "bun dev", + }; + + const copyToClipboard = async () => { + if (copied) return; + await navigator.clipboard.writeText(commands[selectedPM]); setCopied(true); - setIsOpen(false); setTimeout(() => setCopied(false), 2000); }; + const packageManagers: Array<"npm" | "pnpm" | "bun"> = ["bun", "pnpm", "npm"]; + return ( -
-
-
-
-
-
-
+
+
+
+ + Choose your package manager: + +
+ {packageManagers.map((pm) => ( + + ))}
+
-
Terminal
- -
- - - {isOpen && ( - - {(["npm", "pnpm", "bun"] as const).map((pm) => ( - - ))} - +
+
+ $ + + {commands[selectedPM]} + + {step === 0 && ( +
-
-
-
- $ -
- {commands[selectedPM]} - {step === 0 && ( - - )} -
- -
- - {step > 0 && ( -
- {step > 0 && ( -
- Creating a new Better-T-Stack project -
- )} - - {step > 1 && ( -
- Project: - my-app - Frontend: - React Web - Backend: - Hono - Database: - SQLite + Drizzle -
- )} - - {step > 2 && ( -
- ✓ Creating project structure -
- )} - - {step > 3 && ( -
- ✓ Installing dependencies -
- )} - - {step > 4 && ( -
- - Project created successfully! Run: - -
- - cd my-app - - and - - {selectedPM === "npm" - ? "npm run dev" - : selectedPM === "pnpm" - ? "pnpm dev" - : "bun dev"} - -
-
- )} -
- )} - - {step > 4 && ( -
- $ - + {copied ? ( + + + + ) : ( + + + )} - /> -
- )} + + +
-
- For customization options:{" "} - - {selectedPM === "npm" - ? "npx" - : selectedPM === "pnpm" - ? "pnpm dlx" - : "bunx"}{" "} - create-better-t-stack - -
+ + {step > 0 && ( + +
+ {step >= 1 && ( + + Creating a new Better-T-Stack project... + + )} + + {step >= 2 && ( + +
+ Project: + my-app +
+
+ Frontend: + React Web +
+
+ Backend: + Hono +
+
+ Database: + + SQLite + Drizzle + +
+
+ )} + + {step >= 3 && ( + + + Creating project structure + + )} + + {step >= 4 && ( + + + Installing dependencies + + )} + + {step >= 5 && ( + + + {" "} + Project created successfully! Run: + +
+ + cd my-app + + && + + {runCommands[selectedPM]} + +
+
+ )} + + {step >= 5 && ( + + $ + + )} +
+
+ )} +
+ +
); diff --git a/apps/web/src/app/(home)/_components/CustomizableSection.tsx b/apps/web/src/app/(home)/_components/CustomizableSection.tsx index 13e668e..e2bc621 100644 --- a/apps/web/src/app/(home)/_components/CustomizableSection.tsx +++ b/apps/web/src/app/(home)/_components/CustomizableSection.tsx @@ -14,7 +14,7 @@ export default function CustomizableSection() { >

- Your Stack, Your Choice + Roll Your Own Stack

@@ -27,8 +27,7 @@ export default function CustomizableSection() { className="mx-auto max-w-3xl space-y-6" >

- Configure your ideal TypeScript environment with all the options you - need + Build your perfect TypeScript stack.

diff --git a/apps/web/src/app/(home)/_components/Navbar.tsx b/apps/web/src/app/(home)/_components/Navbar.tsx index e803a6a..78fd4cd 100644 --- a/apps/web/src/app/(home)/_components/Navbar.tsx +++ b/apps/web/src/app/(home)/_components/Navbar.tsx @@ -1,312 +1,248 @@ "use client"; +import { ThemeToggle } from "@/components/theme-toggle"; import { cn } from "@/lib/utils"; -import { BookMarked, Github, Maximize2, Menu, X } from "lucide-react"; +import { Github, Maximize2, Menu, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import PackageIcon from "./Icons"; -const Navbar = () => { - const [activeLink, setActiveLink] = useState("home"); - const [bgStyles, setBgStyles] = useState({}); +export default function Navbar() { const [scrolled, setScrolled] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const linkRefs = useRef<{ [key: string]: HTMLAnchorElement | null }>({}); useEffect(() => { - const updateBackground = (linkId: string) => { - const linkElement = linkRefs.current[linkId]; - if (linkElement) { - setBgStyles({ - padding: "0.75rem 0rem", - width: `${linkElement.clientWidth - 12}px`, - transform: `translateX(${linkElement.offsetLeft}px)`, - opacity: 1, - }); - } - }; - - updateBackground(activeLink); - const handleScroll = () => { - const isScrolled = window.scrollY > 50; - setScrolled(isScrolled); + setScrolled(window.scrollY > 10); }; window.addEventListener("scroll", handleScroll); - window.addEventListener("resize", () => updateBackground(activeLink)); + handleScroll(); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + useEffect(() => { + if (mobileMenuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } return () => { - window.removeEventListener("scroll", handleScroll); - window.removeEventListener("resize", () => updateBackground(activeLink)); + document.body.style.overflow = ""; }; - }, [activeLink]); + }, [mobileMenuOpen]); - const toggleMobileMenu = () => { - setMobileMenuOpen(!mobileMenuOpen); - }; + const closeMobileMenu = () => setMobileMenuOpen(false); + + const desktopNavLinks = [ + { + href: "/", + label: "Home", + icon: ~/, + }, + { + href: "https://my-better-t-app-client.pages.dev/", + label: "Demo", + target: "_blank", + }, + { href: "/docs", label: "Docs" }, + { + href: "https://www.npmjs.com/package/create-better-t-stack", + label: "NPM", + icon: , + target: "_blank", + }, + ]; + + const mobileNavLinks = [ + { + href: "/", + label: "Home", + icon: ~/, + }, + { + href: "https://my-better-t-app-client.pages.dev/", + label: "Demo", + target: "_blank", + }, + { href: "/docs", label: "Docs" }, + { + href: "https://www.npmjs.com/package/create-better-t-stack", + label: "NPM", + icon: , + target: "_blank", + }, + { + href: "https://www.github.com/better-t-stack/create-better-t-stack", + label: "GitHub", + icon: , + target: "_blank", + }, + ]; return ( <> - -
-
-
-
-
-
-
-
-
- better-t-stack:~ -
-
- -
-
- - user@better-t-stack +
+ +
+ + $_ - :~$ - ls -la +
+ + Better-T Stack + + + +
+
+ {desktopNavLinks.map((link) => ( + + {link.icon} + {link.label} + + ))}
-
+
+ +
setMobileMenuOpen(false)} + href="/new" + className="inline-flex items-center gap-1.5 rounded-md border border-primary/50 bg-primary/10 px-3 py-1.5 font-mono text-primary text-xs transition-colors hover:bg-primary/20" + title="Stack Builder" > - ~/home + + Builder - - setMobileMenuOpen(false)} - > - ~/demo - - -
- - setMobileMenuOpen(false)} - > - ~/npm - -
- -
- - setMobileMenuOpen(false)} - > - ~/docs - -
- -
- - setMobileMenuOpen(false)} - > - ~/github - -
-
- -
- - user@better-t-stack - - :~$ - star-repo -
- -
setMobileMenuOpen(false)} + className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/90 px-3 py-1.5 font-mono text-muted-foreground text-xs backdrop-blur-sm transition-colors hover:bg-muted hover:text-foreground" + title="Star on GitHub" > - - Star on GitHub + + Star
-
- - user@better-t-stack - - :~$ - â–ˆ -
+ +
+ +
+ +
-
+ + + + {mobileMenuOpen && ( + <> + ); -}; - -export default Navbar; +} diff --git a/apps/web/src/app/(home)/_components/SponsorsSection.tsx b/apps/web/src/app/(home)/_components/SponsorsSection.tsx new file mode 100644 index 0000000..2e947a1 --- /dev/null +++ b/apps/web/src/app/(home)/_components/SponsorsSection.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { motion } from "motion/react"; +import Image from "next/image"; + +export default function SponsorsSection() { + const sectionVariants = { + hidden: { opacity: 0, y: 30 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6, ease: "easeOut" }, + }, + }; + + return ( + +
+

+ Sponsors +

+
+ + + Sponsors + + + +
+ ); +} diff --git a/apps/web/src/app/(home)/_components/StackArchitech.tsx b/apps/web/src/app/(home)/_components/StackArchitech.tsx index c47fb8c..5b29867 100644 --- a/apps/web/src/app/(home)/_components/StackArchitech.tsx +++ b/apps/web/src/app/(home)/_components/StackArchitech.tsx @@ -33,7 +33,7 @@ import { motion } from "motion/react"; import Image from "next/image"; import Link from "next/link"; import { useQueryStates } from "nuqs"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; const validateProjectName = (name: string): string | undefined => { const INVALID_CHARS = ["<", ">", ":", '"', "|", "?", "*"]; @@ -96,37 +96,6 @@ const hasTauriCompatibleFrontend = (frontend: string[]) => ["tanstack-router", "react-router", "nuxt", "svelte"].includes(f), ); -const hasNativeFrontend = (frontend: string[]) => frontend.includes("native"); - -const TechIcon = ({ - icon, - name, - className, -}: { - icon: string; - name: string; - className?: string; -}) => { - if (icon.startsWith("/icon/")) { - return ( - {`${name} - ); - } - - return ( - - {icon} - - ); -}; - const getBadgeColors = (category: string): string => { switch (category) { case "frontend": @@ -159,6 +128,319 @@ const getBadgeColors = (category: string): string => { } }; +const TechIcon = ({ + icon, + name, + className, +}: { + icon: string; + name: string; + className?: string; +}) => { + if (icon.startsWith("/icon/")) { + return ( + {`${name} + ); + } + + return ( + + {icon} + + ); +}; + +const getCategoryDisplayName = (categoryKey: string): string => { + const result = categoryKey.replace(/([A-Z])/g, " $1"); + return result.charAt(0).toUpperCase() + result.slice(1); +}; + +interface CompatibilityResult { + adjustedStack: StackState | null; + notes: Record; +} + +const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { + const nextStack = { ...stack }; + let changed = false; + const notes: CompatibilityResult["notes"] = {}; + for (const cat of CATEGORY_ORDER) { + notes[cat] = { notes: [], hasIssue: false }; + } + + const isWeb = hasWebFrontend(nextStack.frontend); + const isPWACompat = hasPWACompatibleFrontend(nextStack.frontend); + const isTauriCompat = hasTauriCompatibleFrontend(nextStack.frontend); + const isNuxt = nextStack.frontend.includes("nuxt"); + const isSvelte = nextStack.frontend.includes("svelte"); + + if (nextStack.database === "none") { + if (nextStack.orm !== "none") { + notes.database.notes.push( + "Database 'None' selected: ORM will be set to 'None'.", + ); + notes.orm.notes.push( + "ORM requires a database. It will be set to 'None'.", + ); + notes.database.hasIssue = true; + notes.orm.hasIssue = true; + nextStack.orm = "none"; + changed = true; + } + if (nextStack.auth === "true") { + notes.database.notes.push( + "Database 'None' selected: Auth will be disabled.", + ); + notes.auth.notes.push( + "Authentication requires a database. It will be disabled.", + ); + notes.database.hasIssue = true; + notes.auth.hasIssue = true; + nextStack.auth = "false"; + changed = true; + } + if (nextStack.dbSetup !== "none") { + notes.database.notes.push( + "Database 'None' selected: DB Setup will be set to 'Basic'.", + ); + notes.dbSetup.notes.push( + "DB Setup requires a database. It will be set to 'Basic Setup'.", + ); + notes.database.hasIssue = true; + notes.dbSetup.hasIssue = true; + nextStack.dbSetup = "none"; + changed = true; + } + } else if (nextStack.database === "mongodb") { + if (nextStack.orm !== "prisma") { + notes.database.notes.push( + "MongoDB requires Prisma ORM. It will be selected.", + ); + notes.orm.notes.push("MongoDB requires Prisma ORM. It will be selected."); + notes.database.hasIssue = true; + notes.orm.hasIssue = true; + nextStack.orm = "prisma"; + changed = true; + } + } + + if (nextStack.dbSetup === "turso") { + if (nextStack.database !== "sqlite") { + notes.dbSetup.notes.push("Turso requires SQLite. It will be selected."); + notes.database.notes.push( + "Turso DB setup requires SQLite. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + notes.database.hasIssue = true; + nextStack.database = "sqlite"; + changed = true; + } + if (nextStack.orm !== "drizzle") { + notes.dbSetup.notes.push( + "Turso requires Drizzle ORM. It will be selected.", + ); + notes.orm.notes.push( + "Turso DB setup requires Drizzle ORM. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + notes.orm.hasIssue = true; + nextStack.orm = "drizzle"; + changed = true; + } + } else if (nextStack.dbSetup === "prisma-postgres") { + if (nextStack.database !== "postgres") { + notes.dbSetup.notes.push("Requires PostgreSQL. It will be selected."); + notes.database.notes.push( + "Prisma PostgreSQL setup requires PostgreSQL. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + notes.database.hasIssue = true; + nextStack.database = "postgres"; + changed = true; + } + if (nextStack.orm !== "prisma") { + notes.dbSetup.notes.push("Requires Prisma ORM. It will be selected."); + notes.orm.notes.push( + "Prisma PostgreSQL setup requires Prisma ORM. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + notes.orm.hasIssue = true; + nextStack.orm = "prisma"; + changed = true; + } + } else if (nextStack.dbSetup === "mongodb-atlas") { + if (nextStack.database !== "mongodb") { + notes.dbSetup.notes.push("Requires MongoDB. It will be selected."); + notes.database.notes.push( + "MongoDB Atlas setup requires MongoDB. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + notes.database.hasIssue = true; + nextStack.database = "mongodb"; + changed = true; + } + if (nextStack.orm !== "prisma") { + notes.dbSetup.notes.push("Requires Prisma ORM. It will be selected."); + notes.orm.notes.push( + "MongoDB Atlas setup requires Prisma ORM. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + notes.orm.hasIssue = true; + nextStack.orm = "prisma"; + changed = true; + } + } else if (nextStack.dbSetup === "neon") { + if (nextStack.database !== "postgres") { + notes.dbSetup.notes.push( + "Neon requires PostgreSQL. It will be selected.", + ); + notes.database.notes.push( + "Neon DB setup requires PostgreSQL. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + notes.database.hasIssue = true; + nextStack.database = "postgres"; + changed = true; + } + } + + if ((isNuxt || isSvelte) && nextStack.api === "trpc") { + const frontendName = isNuxt ? "Nuxt" : "Svelte"; + notes.api.notes.push( + `${frontendName} requires oRPC. It will be selected automatically.`, + ); + notes.frontend.notes.push( + `Selected ${frontendName}: API will be set to oRPC.`, + ); + notes.api.hasIssue = true; + notes.frontend.hasIssue = true; + nextStack.api = "orpc"; + changed = true; + } + + const incompatibleAddons: string[] = []; + if (!isPWACompat && nextStack.addons.includes("pwa")) { + incompatibleAddons.push("pwa"); + notes.frontend.notes.push( + "PWA addon requires TanStack or React Router. Addon will be removed.", + ); + notes.addons.notes.push( + "PWA requires TanStack/React Router. It will be removed.", + ); + notes.frontend.hasIssue = true; + notes.addons.hasIssue = true; + } + if (!isTauriCompat && nextStack.addons.includes("tauri")) { + incompatibleAddons.push("tauri"); + notes.frontend.notes.push( + "Tauri addon requires TanStack Router, React Router, or Nuxt. Addon will be removed.", + ); + notes.addons.notes.push( + "Tauri requires TanStack/React Router/Nuxt. It will be removed.", + ); + notes.frontend.hasIssue = true; + notes.addons.hasIssue = true; + } + + const originalAddonsLength = nextStack.addons.length; + if (incompatibleAddons.length > 0) { + nextStack.addons = nextStack.addons.filter( + (addon) => !incompatibleAddons.includes(addon), + ); + if (nextStack.addons.length !== originalAddonsLength) changed = true; + } + + if ( + nextStack.addons.includes("husky") && + !nextStack.addons.includes("biome") + ) { + notes.addons.notes.push( + "Husky automatically enables Biome. It will be added.", + ); + notes.addons.hasIssue = true; + nextStack.addons.push("biome"); + nextStack.addons = [...new Set(nextStack.addons)]; + changed = true; + } + + const incompatibleExamples: string[] = []; + if (!isWeb) { + if (nextStack.examples.includes("todo")) incompatibleExamples.push("todo"); + if (nextStack.examples.includes("ai")) incompatibleExamples.push("ai"); + } + if (nextStack.database === "none" && nextStack.examples.includes("todo")) { + incompatibleExamples.push("todo"); + } + if ( + nextStack.backendFramework === "elysia" && + nextStack.examples.includes("ai") + ) { + incompatibleExamples.push("ai"); + } + + const uniqueIncompatibleExamples = [...new Set(incompatibleExamples)]; + if (uniqueIncompatibleExamples.length > 0) { + if ( + !isWeb && + (uniqueIncompatibleExamples.includes("todo") || + uniqueIncompatibleExamples.includes("ai")) + ) { + notes.frontend.notes.push( + "Examples require a web frontend. Incompatible examples will be removed.", + ); + notes.examples.notes.push( + "Requires a web frontend. Incompatible examples will be removed.", + ); + notes.frontend.hasIssue = true; + notes.examples.hasIssue = true; + } + if ( + nextStack.database === "none" && + uniqueIncompatibleExamples.includes("todo") + ) { + notes.database.notes.push( + "Todo example requires a database. It will be removed.", + ); + notes.examples.notes.push( + "Todo example requires a database. It will be removed.", + ); + notes.database.hasIssue = true; + notes.examples.hasIssue = true; + } + if ( + nextStack.backendFramework === "elysia" && + uniqueIncompatibleExamples.includes("ai") + ) { + notes.backendFramework.notes.push( + "AI example is not compatible with Elysia. It will be removed.", + ); + notes.examples.notes.push( + "AI example is not compatible with Elysia. It will be removed.", + ); + notes.backendFramework.hasIssue = true; + notes.examples.hasIssue = true; + } + + const originalExamplesLength = nextStack.examples.length; + nextStack.examples = nextStack.examples.filter( + (ex) => !uniqueIncompatibleExamples.includes(ex), + ); + if (nextStack.examples.length !== originalExamplesLength) changed = true; + } + + return { + adjustedStack: changed ? nextStack : null, + notes, + }; +}; + const StackArchitect = () => { const [stack, setStack] = useQueryStates( stackParsers, @@ -167,9 +449,6 @@ const StackArchitect = () => { const [command, setCommand] = useState(""); const [copied, setCopied] = useState(false); - const [compatNotes, setCompatNotes] = useState< - Record - >({}); const [projectNameError, setProjectNameError] = useState( undefined, ); @@ -183,23 +462,16 @@ const StackArchitect = () => { const sectionRefs = useRef>({}); const contentRef = useRef(null); - const currentHasWebFrontend = useMemo( - () => hasWebFrontend(stack.frontend), - [stack.frontend], + const currentHasWebFrontend = hasWebFrontend(stack.frontend); + const currentHasPWACompatibleFrontend = hasPWACompatibleFrontend( + stack.frontend, ); - const currentHasPWACompatibleFrontend = useMemo( - () => hasPWACompatibleFrontend(stack.frontend), - [stack.frontend], - ); - const currentHasTauriCompatibleFrontend = useMemo( - () => hasTauriCompatibleFrontend(stack.frontend), - [stack.frontend], - ); - const currentHasNativeFrontend = useMemo( - () => hasNativeFrontend(stack.frontend), - [stack.frontend], + const currentHasTauriCompatibleFrontend = hasTauriCompatibleFrontend( + stack.frontend, ); + const compatibilityAnalysis = analyzeStackCompatibility(stack); + useEffect(() => { const savedStack = localStorage.getItem("betterTStackPreference"); if (savedStack) { @@ -213,127 +485,13 @@ const StackArchitect = () => { } }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { - const calculateAdjustedStack = ( - currentStack: StackState, - ): StackState | null => { - const nextStack = { ...currentStack }; - let changed = false; - - const isWeb = hasWebFrontend(nextStack.frontend); - const isPWACompat = hasPWACompatibleFrontend(nextStack.frontend); - const isTauriCompat = hasTauriCompatibleFrontend(nextStack.frontend); - const isNuxt = nextStack.frontend.includes("nuxt"); - const isSvelte = nextStack.frontend.includes("svelte"); - - if (nextStack.database === "none") { - if (nextStack.orm !== "none") { - nextStack.orm = "none"; - changed = true; - } - if (nextStack.auth === "true") { - nextStack.auth = "false"; - changed = true; - } - if (nextStack.dbSetup !== "none") { - nextStack.dbSetup = "none"; - changed = true; - } - } - if (nextStack.database === "mongodb" && nextStack.orm !== "prisma") { - nextStack.orm = "prisma"; - changed = true; - } - if (nextStack.dbSetup === "turso") { - if (nextStack.database !== "sqlite") { - nextStack.database = "sqlite"; - changed = true; - } - if (nextStack.orm !== "drizzle") { - nextStack.orm = "drizzle"; - changed = true; - } - } - if (nextStack.dbSetup === "prisma-postgres") { - if (nextStack.database !== "postgres") { - nextStack.database = "postgres"; - changed = true; - } - if (nextStack.orm !== "prisma") { - nextStack.orm = "prisma"; - changed = true; - } - } - if (nextStack.dbSetup === "mongodb-atlas") { - if (nextStack.database !== "mongodb") { - nextStack.database = "mongodb"; - changed = true; - } - if (nextStack.orm !== "prisma") { - nextStack.orm = "prisma"; - changed = true; - } - } - if (nextStack.dbSetup === "neon" && nextStack.database !== "postgres") { - nextStack.database = "postgres"; - changed = true; - } - - if ((isNuxt || isSvelte) && nextStack.api === "trpc") { - nextStack.api = "orpc"; - changed = true; - } - - const incompatibleAddons: string[] = []; - if (!isPWACompat) incompatibleAddons.push("pwa"); - if (!isTauriCompat) incompatibleAddons.push("tauri"); - const originalAddonsLength = nextStack.addons.length; - nextStack.addons = nextStack.addons.filter( - (addon) => !incompatibleAddons.includes(addon), - ); - if (nextStack.addons.length !== originalAddonsLength) changed = true; - if ( - nextStack.addons.includes("husky") && - !nextStack.addons.includes("biome") - ) { - nextStack.addons.push("biome"); - nextStack.addons = [...new Set(nextStack.addons)]; - changed = true; - } - - const incompatibleExamples: string[] = []; - if (!isWeb) incompatibleExamples.push("todo", "ai"); - if (nextStack.database === "none") incompatibleExamples.push("todo"); - if (nextStack.backendFramework === "elysia") - incompatibleExamples.push("ai"); - const originalExamplesLength = nextStack.examples.length; - nextStack.examples = nextStack.examples.filter( - (ex) => !incompatibleExamples.includes(ex), - ); - if (nextStack.examples.length !== originalExamplesLength) changed = true; - - return changed ? nextStack : null; - }; - - const adjustedStack = calculateAdjustedStack(stack); - if (adjustedStack) { - setStack(adjustedStack); + if (compatibilityAnalysis.adjustedStack) { + setStack(compatibilityAnalysis.adjustedStack); } - }, [ - stack.database, - stack.orm, - stack.auth, - stack.dbSetup, - stack.frontend, - stack.api, - stack.addons, - stack.examples, - stack.backendFramework, - setStack, - ]); + }, [compatibilityAnalysis.adjustedStack, setStack]); - const generateCommand = useCallback((stackState: StackState) => { + const generateCommand = (stackState: StackState) => { let base: string; switch (stackState.packageManager) { case "npm": @@ -381,19 +539,17 @@ const StackArchitect = () => { flags.push(`--database ${stackState.database}`); } - if (stackState.database === "none") { - flags.push("--orm none"); - } else if (!isDefault("orm", stackState.orm)) { + if (stackState.orm !== stackParsers.orm.defaultValue) { flags.push(`--orm ${stackState.orm}`); } - if (!isDefault("auth", stackState.auth)) { + if (stackState.auth !== stackParsers.auth.defaultValue) { if (stackState.auth === "false") { flags.push("--no-auth"); } } - if (!isDefault("dbSetup", stackState.dbSetup)) { + if (stackState.dbSetup !== stackParsers.dbSetup.defaultValue) { flags.push(`--db-setup ${stackState.dbSetup}`); } @@ -405,7 +561,7 @@ const StackArchitect = () => { flags.push(`--runtime ${stackState.runtime}`); } - if (!isDefault("api", stackState.api)) { + if (stackState.api !== stackParsers.api.defaultValue) { flags.push(`--api ${stackState.api}`); } @@ -428,216 +584,51 @@ const StackArchitect = () => { if (!isDefault("addons", stackState.addons)) { if (stackState.addons.length > 0) { flags.push(`--addons ${stackState.addons.join(" ")}`); + } else { } } if (!isDefault("examples", stackState.examples)) { if (stackState.examples.length > 0) { flags.push(`--examples ${stackState.examples.join(" ")}`); + } else { } } return `${base} ${projectName}${ flags.length > 0 ? ` ${flags.join(" ")}` : "" }`; - }, []); + }; - // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { const cmd = generateCommand(stack); setCommand(cmd); + // biome-ignore lint/correctness/useExhaustiveDependencies: + }, [stack, generateCommand]); - const notes: Record = {}; - for (const cat of CATEGORY_ORDER) { - notes[cat] = { notes: [], hasIssue: false }; - } + useEffect(() => { + setProjectNameError(validateProjectName(stack.projectName || "")); + }, [stack.projectName]); - const isWeb = currentHasWebFrontend; - const isPWACompat = currentHasPWACompatibleFrontend; - const isTauriCompat = currentHasTauriCompatibleFrontend; - const isNuxt = stack.frontend.includes("nuxt"); - const isSvelte = stack.frontend.includes("svelte"); + const handleTechSelect = ( + category: keyof typeof TECH_OPTIONS, + techId: string, + ) => { + setStack((currentStack) => { + const catKey = category as keyof StackState; + const update: Partial = {}; + const currentValue = currentStack[catKey]; - if (!isPWACompat && stack.addons.includes("pwa")) { - notes.frontend.notes.push("PWA addon requires TanStack or React Router."); - notes.frontend.hasIssue = true; - notes.addons.hasIssue = true; - } - if (!isTauriCompat && stack.addons.includes("tauri")) { - notes.frontend.notes.push( - "Tauri addon requires TanStack Router, React Router, or Nuxt.", - ); - notes.frontend.hasIssue = true; - notes.addons.hasIssue = true; - } - - if (!isWeb && stack.examples.length > 0) { - notes.frontend.notes.push("Examples require a web frontend."); - notes.frontend.hasIssue = true; - notes.examples.hasIssue = true; - } - - if ((isNuxt || isSvelte) && stack.api === "trpc") { - notes.api.notes.push( - "Nuxt requires oRPC. It will be selected automatically.", - `${ - isNuxt ? "Nuxt" : "Svelte" - } requires oRPC. It will be selected automatically.`, - ); - notes.api.hasIssue = true; - notes.frontend.hasIssue = true; - } - - if (stack.database === "mongodb" && stack.orm !== "prisma") { - notes.database.notes.push( - "MongoDB requires Prisma ORM. It will be selected automatically.", - ); - notes.database.hasIssue = true; - notes.orm.hasIssue = true; - } - if (stack.database === "none") { - if (stack.orm !== "none") notes.database.hasIssue = true; - if (stack.auth === "true") notes.database.hasIssue = true; - if (stack.dbSetup !== "none") notes.database.hasIssue = true; - if (stack.examples.includes("todo")) notes.database.hasIssue = true; - } - - if (stack.database === "none" && stack.orm !== "none") { - notes.orm.notes.push( - "ORM requires a database. It will be set to 'None'.", - ); - notes.orm.hasIssue = true; - } - if (stack.database === "mongodb" && stack.orm !== "prisma") { - notes.orm.notes.push("MongoDB requires Prisma ORM. It will be selected."); - notes.orm.hasIssue = true; - } - if (stack.dbSetup === "turso" && stack.orm !== "drizzle") { - notes.orm.notes.push( - "Turso DB setup requires Drizzle ORM. It will be selected.", - ); - notes.orm.hasIssue = true; - } - if (stack.dbSetup === "prisma-postgres" && stack.orm !== "prisma") { - notes.orm.notes.push( - "Prisma PostgreSQL setup requires Prisma ORM. It will be selected.", - ); - notes.orm.hasIssue = true; - } - if (stack.dbSetup === "mongodb-atlas" && stack.orm !== "prisma") { - notes.orm.notes.push( - "MongoDB Atlas setup requires Prisma ORM. It will be selected.", - ); - notes.orm.hasIssue = true; - } - - if (stack.database === "none" && stack.dbSetup !== "none") { - notes.dbSetup.notes.push( - "DB Setup requires a database. It will be set to 'Basic Setup'.", - ); - notes.dbSetup.hasIssue = true; - } else if (stack.dbSetup !== "none") { - const db = stack.database; - const orm = stack.orm; - if (stack.dbSetup === "turso" && (db !== "sqlite" || orm !== "drizzle")) { - notes.dbSetup.notes.push( - "Turso requires SQLite & Drizzle. They will be selected.", - ); - notes.dbSetup.hasIssue = true; - if (db !== "sqlite") notes.database.hasIssue = true; - if (orm !== "drizzle") notes.orm.hasIssue = true; - } else if ( - stack.dbSetup === "prisma-postgres" && - (db !== "postgres" || orm !== "prisma") + if ( + catKey === "frontend" || + catKey === "addons" || + catKey === "examples" ) { - notes.dbSetup.notes.push( - "Requires PostgreSQL & Prisma. They will be selected.", - ); - notes.dbSetup.hasIssue = true; - if (db !== "postgres") notes.database.hasIssue = true; - if (orm !== "prisma") notes.orm.hasIssue = true; - } else if ( - stack.dbSetup === "mongodb-atlas" && - (db !== "mongodb" || orm !== "prisma") - ) { - notes.dbSetup.notes.push( - "Requires MongoDB & Prisma. They will be selected.", - ); - notes.dbSetup.hasIssue = true; - if (db !== "mongodb") notes.database.hasIssue = true; - if (orm !== "prisma") notes.orm.hasIssue = true; - } else if (stack.dbSetup === "neon" && db !== "postgres") { - notes.dbSetup.notes.push( - "Neon requires PostgreSQL. It will be selected.", - ); - notes.dbSetup.hasIssue = true; - if (db !== "postgres") notes.database.hasIssue = true; - } - } - - if (stack.database === "none" && stack.auth === "true") { - notes.auth.notes.push( - "Authentication requires a database. It will be disabled.", - ); - notes.auth.hasIssue = true; - } - - if ( - !isPWACompat && - stack.addons.some((a) => ["pwa", "tauri"].includes(a)) - ) { - notes.addons.notes.push( - "PWA/Tauri require TanStack/React Router. They will be removed.", - ); - notes.addons.hasIssue = true; - } - if (stack.addons.includes("husky") && !stack.addons.includes("biome")) { - notes.addons.notes.push( - "Husky automatically enables Biome. It will be added.", - ); - } - - if (!isWeb && stack.examples.length > 0) { - notes.examples.notes.push( - "Examples require a web frontend. They will be removed.", - ); - notes.examples.hasIssue = true; - } - if (stack.database === "none" && stack.examples.includes("todo")) { - notes.examples.notes.push( - "Todo example requires a database. It will be removed.", - ); - notes.examples.hasIssue = true; - } - if (stack.backendFramework === "elysia" && stack.examples.includes("ai")) { - notes.examples.notes.push( - "AI example is not compatible with Elysia. It will be removed.", - ); - notes.examples.hasIssue = true; - notes.backendFramework.hasIssue = true; - } - - setCompatNotes(notes); - }, [ - stack, - generateCommand, - currentHasWebFrontend, - currentHasPWACompatibleFrontend, - currentHasTauriCompatibleFrontend, - currentHasNativeFrontend, - ]); - - const handleTechSelect = useCallback( - (category: keyof typeof TECH_OPTIONS, techId: string) => { - setStack((currentStack) => { - const catKey = category as keyof StackState; - const update: Partial = {}; + const currentArray = [...(currentValue as string[])]; + let nextArray = [...currentArray]; + const isSelected = currentArray.includes(techId); if (catKey === "frontend") { - const currentArray = [...(currentStack[catKey] as string[])]; - let nextArray = [...currentArray]; - const isSelected = currentArray.includes(techId); - const webTypes = [ "tanstack-router", "react-router", @@ -646,12 +637,12 @@ const StackArchitect = () => { "nuxt", "svelte", ]; - if (techId === "none") { nextArray = ["none"]; } else if (isSelected) { - nextArray = nextArray.filter((id) => id !== techId); - if (nextArray.length === 0) { + if (currentArray.length > 1) { + nextArray = nextArray.filter((id) => id !== techId); + } else { return {}; } } else { @@ -661,175 +652,185 @@ const StackArchitect = () => { } nextArray.push(techId); } - - const selectedWeb = nextArray.filter((id) => webTypes.includes(id)); - if (selectedWeb.length > 1) { - nextArray = nextArray.filter((id) => !webTypes.includes(id)); - nextArray.push(techId); - } - - if ( - JSON.stringify([...new Set(nextArray)].sort()) !== - JSON.stringify(currentArray.sort()) - ) { - update[catKey] = [...new Set(nextArray)]; - } - } else if (catKey === "addons" || catKey === "examples") { - const currentArray = [...(currentStack[catKey] as string[])]; - let nextArray = [...currentArray]; - const isSelected = currentArray.includes(techId); + } else { if (isSelected) { nextArray = nextArray.filter((id) => id !== techId); } else { nextArray.push(techId); } + } + + const uniqueNext = [...new Set(nextArray)].sort(); + if ( + JSON.stringify(uniqueNext) !== + JSON.stringify([...new Set(currentArray)].sort()) + ) { + update[catKey] = uniqueNext; + } + } else { + if (currentValue !== techId) { + const techOption = TECH_OPTIONS[category]?.find( + (opt) => opt.id === techId, + ); + const isBooleanLike = + category === "auth" || category === "git" || category === "install"; if ( - JSON.stringify([...new Set(nextArray)].sort()) !== - JSON.stringify(currentArray.sort()) + currentValue === techId && + techId !== "none" && + !isBooleanLike && + techOption?.id !== "none" ) { - update[catKey] = [...new Set(nextArray)]; + return {}; } + update[catKey] = techId; } else { - if (currentStack[catKey] !== techId) { - update[catKey] = techId; + if ( + (category === "git" || + category === "install" || + category === "auth") && + techId === "false" + ) { + update[catKey] = "true"; + } else { + return {}; } } + } - return update; - }); - }, - [setStack], - ); + return Object.keys(update).length > 0 ? update : {}; + }); + }; - // biome-ignore lint/correctness/useExhaustiveDependencies: - const getDisabledReason = useCallback( - (category: keyof typeof TECH_OPTIONS, techId: string): string | null => { - const catKey = category as keyof StackState; + const getDisabledReason = ( + category: keyof typeof TECH_OPTIONS, + techId: string, + ): string | null => { + const catKey = category as keyof StackState; + if (catKey === "api") { if ( - catKey === "frontend" && - Array.isArray(stack[catKey]) && - (stack[catKey] as string[]).length === 1 && - (stack[catKey] as string[])[0] === techId && + techId === "trpc" && + (stack.frontend.includes("nuxt") || stack.frontend.includes("svelte")) + ) { + return `tRPC is not supported with ${ + stack.frontend.includes("nuxt") ? "Nuxt" : "Svelte" + }. Use oRPC instead.`; + } + } + + if (catKey === "orm") { + if (stack.database === "none") + return "Select a database to enable ORM options."; + if ( + stack.database === "mongodb" && + techId !== "prisma" && techId !== "none" - ) { - return "At least one frontend option must be selected."; - } - + ) + return "MongoDB requires the Prisma ORM."; if ( - !( - catKey === "frontend" || - catKey === "addons" || - catKey === "examples" - ) && - stack[catKey] === techId - ) { - if (techId !== "none" && techId !== "false") { - return "This option is currently selected."; - } + stack.dbSetup === "turso" && + techId !== "drizzle" && + techId !== "none" + ) + return "Turso DB setup requires the Drizzle ORM."; + if ( + stack.dbSetup === "prisma-postgres" && + techId !== "prisma" && + techId !== "none" + ) + return "Prisma PostgreSQL setup requires Prisma ORM."; + if ( + stack.dbSetup === "mongodb-atlas" && + techId !== "prisma" && + techId !== "none" + ) + return "MongoDB Atlas setup requires Prisma ORM."; + + if (techId === "none" && stack.database === "mongodb") + return "MongoDB requires Prisma ORM."; + if (techId === "none" && stack.dbSetup === "turso") + return "Turso DB setup requires Drizzle ORM."; + if ( + techId === "none" && + (stack.dbSetup === "prisma-postgres" || + stack.dbSetup === "mongodb-atlas") + ) + return "This DB setup requires Prisma ORM."; + } + + if (catKey === "dbSetup" && techId !== "none") { + if (stack.database === "none") + return "Select a database before choosing a cloud setup."; + + if (techId === "turso") { + if (stack.database !== "sqlite" && stack.database !== "none") + return "Turso requires SQLite database."; + if (stack.orm !== "drizzle" && stack.orm !== "none") + return "Turso requires Drizzle ORM."; + } else if (techId === "prisma-postgres") { + if (stack.database !== "postgres" && stack.database !== "none") + return "Requires PostgreSQL database."; + if (stack.orm !== "prisma" && stack.orm !== "none") + return "Requires Prisma ORM."; + } else if (techId === "mongodb-atlas") { + if (stack.database !== "mongodb" && stack.database !== "none") + return "Requires MongoDB database."; + if (stack.orm !== "prisma" && stack.orm !== "none") + return "Requires Prisma ORM."; + } else if (techId === "neon") { + if (stack.database !== "postgres" && stack.database !== "none") + return "Requires PostgreSQL database."; } + } - if (catKey === "api") { - if (techId === "trpc" && stack.frontend.includes("nuxt")) { - return "tRPC is not supported with Nuxt. Use oRPC instead."; - } - if (techId === "trpc" && stack.frontend.includes("svelte")) { - return "tRPC is not supported with Svelte. Use oRPC instead."; - } + if (catKey === "auth" && techId === "true" && stack.database === "none") { + return "Authentication requires a database."; + } + + if (catKey === "addons") { + if (techId === "pwa" && !currentHasPWACompatibleFrontend) { + return "Requires TanStack Router or React Router frontend."; } - - if (catKey === "orm") { - if (stack.database === "none") - return "Select a database to enable ORM options."; - if (stack.database === "mongodb" && techId === "drizzle") - return "MongoDB requires the Prisma ORM."; - if (stack.dbSetup === "turso" && techId === "prisma") - return "Turso DB setup requires the Drizzle ORM."; - if (techId === "none" && stack.database === "mongodb") - return "MongoDB requires Prisma ORM."; + if (techId === "tauri" && !currentHasTauriCompatibleFrontend) { + return "Requires TanStack Router, React Router, or Nuxt frontend."; } + } - if (catKey === "dbSetup" && techId !== "none") { - if (stack.database === "none") - return "Select a database before choosing a cloud setup."; - - if (techId === "turso") { - if (stack.database !== "sqlite") - return "Turso requires SQLite database."; - if (stack.orm === "prisma") return "Turso requires Drizzle ORM."; - } else if (techId === "prisma-postgres") { - if (stack.database !== "postgres") - return "Requires PostgreSQL database."; - if (stack.orm !== "prisma") return "Requires Prisma ORM."; - } else if (techId === "mongodb-atlas") { - if (stack.database !== "mongodb") return "Requires MongoDB database."; - if (stack.orm !== "prisma") return "Requires Prisma ORM."; - } else if (techId === "neon") { - if (stack.database !== "postgres") - return "Requires PostgreSQL database."; - } + if (catKey === "examples") { + if ((techId === "todo" || techId === "ai") && !currentHasWebFrontend) { + return "Requires a web frontend (TanStack Router, React Router, etc.)."; } - - if (catKey === "auth" && techId === "true" && stack.database === "none") { - return "Authentication requires a database."; + if (techId === "todo" && stack.database === "none") { + return "Todo example requires a database."; } - - if (catKey === "addons") { - if (techId === "pwa" && !currentHasPWACompatibleFrontend) { - return "Requires TanStack Router or React Router frontend."; - } - if (techId === "tauri" && !currentHasTauriCompatibleFrontend) { - return "Requires TanStack Router, React Router, or Nuxt frontend."; - } + if (techId === "ai" && stack.backendFramework === "elysia") { + return "AI example is not compatible with Elysia backend."; } + } - if (catKey === "examples") { - if ((techId === "todo" || techId === "ai") && !currentHasWebFrontend) { - return "Requires a web frontend (TanStack Router, React Router, etc.)."; - } - if (techId === "todo" && stack.database === "none") { - return "Todo example requires a database."; - } - if (techId === "ai" && stack.backendFramework === "elysia") { - return "AI example is not compatible with Elysia backend."; - } - } + return null; + }; - return null; - }, - [ - stack, - currentHasNativeFrontend, - currentHasPWACompatibleFrontend, - currentHasTauriCompatibleFrontend, - currentHasWebFrontend, - ], - ); - - const copyToClipboard = useCallback(() => { + const copyToClipboard = () => { navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 2000); - }, [command]); + }; - const resetStack = useCallback(() => { + const resetStack = () => { setStack(DEFAULT_STACK); setShowHelp(false); setShowPresets(false); setActiveCategory(CATEGORY_ORDER[0]); contentRef.current?.scrollTo(0, 0); - }, [setStack]); + }; - useEffect(() => { - setProjectNameError(validateProjectName(stack.projectName || "")); - }, [stack.projectName]); - - const saveCurrentStack = useCallback(() => { + const saveCurrentStack = () => { localStorage.setItem("betterTStackPreference", JSON.stringify(stack)); setLastSavedStack(stack); - }, [stack]); + }; - const loadSavedStack = useCallback(() => { + const loadSavedStack = () => { if (lastSavedStack) { setStack(lastSavedStack); setShowHelp(false); @@ -837,35 +838,36 @@ const StackArchitect = () => { setActiveCategory(CATEGORY_ORDER[0]); contentRef.current?.scrollTo(0, 0); } - }, [lastSavedStack, setStack]); + }; - const applyPreset = useCallback( - (presetId: string) => { - const preset = PRESET_TEMPLATES.find( - (template) => template.id === presetId, - ); - if (preset) { - setStack(preset.stack); - setShowPresets(false); - setShowHelp(false); - setActiveCategory(CATEGORY_ORDER[0]); - contentRef.current?.scrollTo(0, 0); - } - }, - [setStack], - ); + const applyPreset = (presetId: string) => { + const preset = PRESET_TEMPLATES.find( + (template) => template.id === presetId, + ); + if (preset) { + setStack(preset.stack); + setShowPresets(false); + setShowHelp(false); + setActiveCategory(CATEGORY_ORDER[0]); + contentRef.current?.scrollTo(0, 0); + } + }; const handleSidebarClick = (category: string) => { setActiveCategory(category); const element = sectionRefs.current[category]; - if (element) { - element.scrollIntoView({ behavior: "smooth", block: "start" }); - } - }; + if (element && contentRef.current) { + const containerTop = contentRef.current.getBoundingClientRect().top; + const elementTop = element.getBoundingClientRect().top; + const scrollTop = contentRef.current.scrollTop; + const offset = 16; + const targetScrollTop = scrollTop + elementTop - containerTop - offset; - const getCategoryDisplayName = (categoryKey: string): string => { - const result = categoryKey.replace(/([A-Z])/g, " $1"); - return result.charAt(0).toUpperCase() + result.slice(1); + contentRef.current.scrollTo({ + top: targetScrollTop, + behavior: "smooth", + }); + } }; return ( @@ -1172,7 +1174,7 @@ const StackArchitect = () => { )} > {getCategoryDisplayName(category)} - {compatNotes[category]?.hasIssue && ( + {compatibilityAnalysis.notes[category]?.hasIssue && ( {" "} @@ -1183,11 +1185,8 @@ const StackArchitect = () => { - -
+ +
{CATEGORY_ORDER.map((categoryKey) => { const categoryOptions = TECH_OPTIONS[categoryKey as keyof typeof TECH_OPTIONS] || []; @@ -1207,6 +1206,23 @@ const StackArchitect = () => {

{categoryDisplayName}

+ {compatibilityAnalysis.notes[categoryKey]?.notes.length > + 0 && ( + + + + + +
    + {compatibilityAnalysis.notes[ + categoryKey + ].notes.map((note) => ( +
  • {note}
  • + ))} +
+
+
+ )}
@@ -1230,6 +1246,7 @@ const StackArchitect = () => { categoryKey as keyof typeof TECH_OPTIONS, tech.id, ); + const isDisabled = !!disabledReason && !isSelected; return ( diff --git a/apps/web/src/app/(home)/_components/Testimonials.tsx b/apps/web/src/app/(home)/_components/Testimonials.tsx index a1f1ba7..b5d7d44 100644 --- a/apps/web/src/app/(home)/_components/Testimonials.tsx +++ b/apps/web/src/app/(home)/_components/Testimonials.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { motion } from "motion/react"; -import { useEffect, useState } from "react"; +import { useMemo, useState } from "react"; import { Tweet } from "react-tweet"; const TWEET_IDS = [ @@ -38,175 +38,192 @@ const TWEET_IDS = [ "1906570888897777847", ]; +const MAX_VISIBLE_PAGES = 5; + export default function Testimonials() { const [startIndex, setStartIndex] = useState(0); - const [tweetsPerPage, setTweetsPerPage] = useState(1); + const [tweetsPerPage] = useState(6); // Show 6 tweets per page - useEffect(() => { - const handleResize = () => { - if (window.innerWidth >= 1280) { - setTweetsPerPage(6); - } else if (window.innerWidth >= 768) { - setTweetsPerPage(4); - } else if (window.innerWidth >= 640) { - setTweetsPerPage(2); - } else { - setTweetsPerPage(1); - } - }; - - handleResize(); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - - const getVisibleTweets = () => { - const visible = []; - for (let i = 0; i < tweetsPerPage; i++) { - const index = (startIndex + i) % TWEET_IDS.length; - visible.push(index); - } - return visible; - }; + const totalPages = useMemo( + () => Math.ceil(TWEET_IDS.length / tweetsPerPage), + [tweetsPerPage], + ); + const currentPage = useMemo( + () => Math.floor(startIndex / tweetsPerPage) + 1, + [startIndex, tweetsPerPage], + ); const handleNext = () => { - setStartIndex((prev) => (prev + tweetsPerPage) % TWEET_IDS.length); + setStartIndex((prev) => + Math.min(prev + tweetsPerPage, (totalPages - 1) * tweetsPerPage), + ); }; const handlePrev = () => { - setStartIndex((prev) => { - const newIndex = prev - tweetsPerPage; - return newIndex < 0 ? TWEET_IDS.length + newIndex : newIndex; - }); + setStartIndex((prev) => Math.max(0, prev - tweetsPerPage)); }; - const visibleTweets = getVisibleTweets(); - const totalPages = Math.ceil(TWEET_IDS.length / tweetsPerPage); - const currentPage = Math.floor(startIndex / tweetsPerPage) + 1; + const goToPage = (pageNumber: number) => { + setStartIndex((pageNumber - 1) * tweetsPerPage); + }; + + const visibleTweetIndices = useMemo(() => { + const end = Math.min(startIndex + tweetsPerPage, TWEET_IDS.length); + return Array.from({ length: end - startIndex }, (_, i) => startIndex + i); + }, [startIndex, tweetsPerPage]); + + const paginationDots = useMemo(() => { + if (totalPages <= MAX_VISIBLE_PAGES) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const startPage = Math.max( + 1, + Math.min( + currentPage - Math.floor(MAX_VISIBLE_PAGES / 2), + totalPages - MAX_VISIBLE_PAGES + 1, + ), + ); + const endPage = Math.min(totalPages, startPage + MAX_VISIBLE_PAGES - 1); + + const pages: (number | string)[] = []; + if (startPage > 1) { + pages.push(1); + if (startPage > 2) pages.push("..."); + } + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + if (endPage < totalPages) { + if (endPage < totalPages - 1) pages.push("..."); + pages.push(totalPages); + } + return pages; + }, [totalPages, currentPage]); + + const sectionVariants = { + hidden: { opacity: 0, y: 30 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6, ease: "easeOut" }, + }, + }; + + const gridVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.1, delayChildren: 0.2 }, + }, + }; return ( -
-
- -

- - Developer Feedback - -

-
- - - what devs are saying about Better-T-Stack - + +
+

+ Loved by Developers +

+

+ See what people are saying about Better-T-Stack on X. +

-
-
-
-
-
-
-
-
- Developer Feedback -
-
- - - - - - - -
+ {visibleTweetIndices.map((index) => ( +
+
- -
-
- {visibleTweets.map((tweetIndex) => ( - - ))} -
-
- -
-
- - {currentPage}/{totalPages} - -
- -
-
- {Array.from({ length: Math.min(totalPages, 5) }).map((_, i) => { - const pageNum = - totalPages <= 5 - ? i - : currentPage <= 3 - ? i - : currentPage >= totalPages - 1 - ? totalPages - 5 + i - : currentPage - 3 + i; - const isActive = pageNum === currentPage - 1; - return ( -
-
-
-
+ ))} -
+ + {totalPages > 1 && ( + + + + Prev + + +
+ {paginationDots.map((page, index) => + typeof page === "number" ? ( + + ) : ( + + index + }`} + className="flex h-8 w-8 items-center justify-center text-muted-foreground text-sm" + aria-hidden="true" + > + ... + + ), + )} +
+
+ Page {currentPage} of {totalPages} +
+ + + Next + + +
+ )} + ); } diff --git a/apps/web/src/app/(home)/page.tsx b/apps/web/src/app/(home)/page.tsx index 465038c..180abbe 100644 --- a/apps/web/src/app/(home)/page.tsx +++ b/apps/web/src/app/(home)/page.tsx @@ -8,6 +8,7 @@ import CustomizableSection from "./_components/CustomizableSection"; import Footer from "./_components/Footer"; import Navbar from "./_components/Navbar"; import NpmPackage from "./_components/NpmPackage"; +import SponsorsSection from "./_components/SponsorsSection"; import Testimonials from "./_components/Testimonials"; export default function HomePage() { @@ -118,10 +119,8 @@ export default function HomePage() {
-
-
-
-
-
@@ -164,6 +160,16 @@ export default function HomePage() { > + + + +