mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
update landing page and fix stack builder logic
This commit is contained in:
@@ -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<HTMLDivElement>(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: <explanation>
|
||||
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 (
|
||||
<div className="mx-auto mt-4 w-full max-w-3xl">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background">
|
||||
<div className="flex items-center justify-between bg-muted px-3 py-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-500" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500" />
|
||||
<div className="mx-auto mt-6 w-full max-w-3xl font-mono">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-muted/30 shadow-sm">
|
||||
<div className="flex items-center justify-between border-border border-b bg-muted/50 px-4 py-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Choose your package manager:
|
||||
</span>
|
||||
<div className="flex items-center rounded-md border border-border bg-background p-0.5">
|
||||
{packageManagers.map((pm) => (
|
||||
<button
|
||||
type="button"
|
||||
key={pm}
|
||||
onClick={() => setSelectedPM(pm)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-xs transition-colors duration-150",
|
||||
selectedPM === pm
|
||||
? "bg-primary/10 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<PackageIcon pm={pm} className="size-3.5" />
|
||||
{pm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-xs">Terminal</div>
|
||||
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-foreground text-xs hover:bg-muted"
|
||||
>
|
||||
<Terminal className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{selectedPM}</span>
|
||||
<svg
|
||||
className="h-3 w-3 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>arrow</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={isOpen ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute right-0 z-50 mt-1 w-28 rounded-md border border-border bg-background"
|
||||
>
|
||||
{(["npm", "pnpm", "bun"] as const).map((pm) => (
|
||||
<button
|
||||
type="button"
|
||||
key={pm}
|
||||
className={cn(
|
||||
"block w-full px-3 py-1.5 text-left text-foreground text-xs",
|
||||
selectedPM === pm ? "bg-muted" : "hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => copyToClipboard(pm)}
|
||||
>
|
||||
{pm === "bun" ? "🥟 bun" : pm}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="relative bg-background p-4 text-sm">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<span className="select-none text-muted-foreground">$</span>
|
||||
<code className="whitespace-pre text-foreground">
|
||||
{commands[selectedPM]}
|
||||
</code>
|
||||
{step === 0 && (
|
||||
<motion.span
|
||||
key="cursor-command"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 1, 0] }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatDelay: 0,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="ml-0.5 inline-block h-4 w-2 flex-shrink-0 bg-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background p-4 text-left font-mono text-sm">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-muted-foreground">$</span>
|
||||
<div className="flex-grow">
|
||||
<span className="text-foreground">{commands[selectedPM]}</span>
|
||||
{step === 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-0.5 inline-block h-4 w-2 bg-foreground",
|
||||
showCursor ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<div className="absolute top-3 right-3">
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(selectedPM)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={copyToClipboard}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded border bg-background text-muted-foreground transition-all duration-150 hover:border-border hover:bg-muted hover:text-foreground",
|
||||
copied
|
||||
? "border-chart-4/50 bg-chart-4/10 text-chart-4"
|
||||
: "border-border",
|
||||
)}
|
||||
aria-label={copied ? "Copied" : "Copy command"}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-[--color-chart-4]" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step > 0 && (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
{step > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Creating a new Better-T-Stack project
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step > 1 && (
|
||||
<div className="ml-2 grid grid-cols-[80px_1fr] gap-x-2 text-muted-foreground text-xs">
|
||||
<span>Project:</span>
|
||||
<span className="text-foreground">my-app</span>
|
||||
<span>Frontend:</span>
|
||||
<span className="text-foreground">React Web</span>
|
||||
<span>Backend:</span>
|
||||
<span className="text-foreground">Hono</span>
|
||||
<span>Database:</span>
|
||||
<span className="text-foreground">SQLite + Drizzle</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step > 2 && (
|
||||
<div className="text-muted-foreground">
|
||||
✓ Creating project structure
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step > 3 && (
|
||||
<div className="text-muted-foreground">
|
||||
✓ Installing dependencies
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step > 4 && (
|
||||
<div className="mt-2 border-border border-l-2 bg-muted py-2 pl-3 text-xs">
|
||||
<span className="font-semibold text-foreground">
|
||||
Project created successfully! Run:
|
||||
</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<code className="rounded bg-secondary px-1 py-0.5 text-secondary-foreground">
|
||||
cd my-app
|
||||
</code>
|
||||
<span className="text-muted-foreground">and</span>
|
||||
<code className="rounded bg-secondary px-1 py-0.5 text-secondary-foreground">
|
||||
{selectedPM === "npm"
|
||||
? "npm run dev"
|
||||
: selectedPM === "pnpm"
|
||||
? "pnpm dev"
|
||||
: "bun dev"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step > 4 && (
|
||||
<div className="mt-3 flex items-center">
|
||||
<span className="mr-2 text-muted-foreground">$</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-2 bg-foreground",
|
||||
showCursor ? "opacity-100" : "opacity-0",
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.5, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.5, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ClipboardCopy className="size-4" />
|
||||
</motion.div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t bg-muted px-4 py-1.5 text-left text-muted-foreground text-xs">
|
||||
For customization options:{" "}
|
||||
<code className="rounded bg-secondary px-1 py-0.5 text-secondary-foreground">
|
||||
{selectedPM === "npm"
|
||||
? "npx"
|
||||
: selectedPM === "pnpm"
|
||||
? "pnpm dlx"
|
||||
: "bunx"}{" "}
|
||||
create-better-t-stack
|
||||
</code>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{step > 0 && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{
|
||||
height: "auto",
|
||||
opacity: 1,
|
||||
transition: {
|
||||
height: { duration: 0.3 },
|
||||
opacity: { duration: 0.2, delay: 0.1 },
|
||||
},
|
||||
}}
|
||||
exit={{ height: 0, opacity: 0, transition: { duration: 0.2 } }}
|
||||
className="overflow-hidden border-border border-t bg-background/70 px-4 pt-3 pb-4"
|
||||
>
|
||||
<div className="space-y-1 text-muted-foreground text-xs">
|
||||
{step >= 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
Creating a new Better-T-Stack project...
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step >= 2 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
className="pt-1"
|
||||
>
|
||||
<div>
|
||||
<span className="inline-block w-20">Project:</span>
|
||||
<span className="text-foreground/90">my-app</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-block w-20">Frontend:</span>
|
||||
<span className="text-foreground/90">React Web</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-block w-20">Backend:</span>
|
||||
<span className="text-foreground/90">Hono</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-block w-20">Database:</span>
|
||||
<span className="text-foreground/90">
|
||||
SQLite + Drizzle
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step >= 3 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
className="flex items-center gap-1.5 pt-1"
|
||||
>
|
||||
<Check className="size-3 flex-shrink-0 text-green-500" />
|
||||
<span>Creating project structure</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step >= 4 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="size-3 flex-shrink-0 text-green-500" />
|
||||
<span>Installing dependencies</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step >= 5 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
className="!mt-3 border-green-500 border-l-2 bg-muted/50 py-2 pl-3"
|
||||
>
|
||||
<span className="block font-medium text-foreground">
|
||||
{" "}
|
||||
Project created successfully! Run:
|
||||
</span>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<code className="rounded bg-secondary px-1.5 py-0.5 text-secondary-foreground">
|
||||
cd my-app
|
||||
</code>
|
||||
<span className="text-muted-foreground">&&</span>
|
||||
<code className="rounded bg-secondary px-1.5 py-0.5 text-secondary-foreground">
|
||||
{runCommands[selectedPM]}
|
||||
</code>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step >= 5 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 }}
|
||||
className="!mt-3 flex items-center gap-2"
|
||||
>
|
||||
<span className="select-none text-muted-foreground">$</span>
|
||||
<motion.span
|
||||
key="cursor-done"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 1, 0] }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatDelay: 0,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="inline-block h-3.5 w-2 flex-shrink-0 bg-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="border-border border-t bg-muted/50 px-4 py-2 text-muted-foreground text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function CustomizableSection() {
|
||||
>
|
||||
<h2 className="font-bold font-mono text-2xl tracking-tight sm:text-3xl md:text-4xl lg:text-5xl">
|
||||
<span className="border-primary border-b-2 pb-1 text-foreground dark:text-primary">
|
||||
Your Stack, Your Choice
|
||||
Roll Your Own Stack
|
||||
</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
@@ -27,8 +27,7 @@ export default function CustomizableSection() {
|
||||
className="mx-auto max-w-3xl space-y-6"
|
||||
>
|
||||
<p className="font-mono text-lg text-muted-foreground leading-relaxed sm:text-xl">
|
||||
Configure your ideal TypeScript environment with all the options you
|
||||
need
|
||||
Build your perfect TypeScript stack.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -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: <span className="text-primary">~/</span>,
|
||||
},
|
||||
{
|
||||
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: <PackageIcon pm="npm" className="h-4 w-4" />,
|
||||
target: "_blank",
|
||||
},
|
||||
];
|
||||
|
||||
const mobileNavLinks = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Home",
|
||||
icon: <span className="text-primary">~/</span>,
|
||||
},
|
||||
{
|
||||
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: <PackageIcon pm="npm" className="h-4 w-4" />,
|
||||
target: "_blank",
|
||||
},
|
||||
{
|
||||
href: "https://www.github.com/better-t-stack/create-better-t-stack",
|
||||
label: "GitHub",
|
||||
icon: <Github className="size-4" />,
|
||||
target: "_blank",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex w-full items-center justify-between px-4 py-4 transition-all duration-300 sm:px-8",
|
||||
scrolled ? "bg-transparent" : "bg-background/80 backdrop-blur-xl",
|
||||
"fixed top-0 z-[100] w-full transition-all duration-300 ease-in-out",
|
||||
scrolled
|
||||
? "border-border border-b bg-background/80 shadow-sm backdrop-blur-md"
|
||||
: "border-transparent border-b bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center space-x-3 transition-opacity duration-300",
|
||||
scrolled ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-sm">
|
||||
<span className="text-md text-primary">$_</span>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground text-md">
|
||||
Better-T Stack
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="-translate-x-1/2 absolute left-1/2 hidden transform md:block">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center rounded-lg border border-border bg-muted/90 px-1.5 py-1 text-sm backdrop-blur-sm transition-all duration-500 ease-out",
|
||||
scrolled ? "w-[420px]" : "w-[313px]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute rounded-md bg-background transition-all duration-200 ease-in-out"
|
||||
style={bgStyles}
|
||||
/>
|
||||
<Link
|
||||
href="/"
|
||||
ref={(ref) => {
|
||||
linkRefs.current.home = ref;
|
||||
}}
|
||||
onMouseOver={() => setActiveLink("home")}
|
||||
className="relative flex items-center gap-1 rounded-md px-4 py-2 font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span className="text-primary">~/</span>
|
||||
home
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://my-better-t-app-client.pages.dev/"
|
||||
target="_blank"
|
||||
ref={(ref) => {
|
||||
linkRefs.current.demo = ref;
|
||||
}}
|
||||
onMouseOver={() => setActiveLink("demo")}
|
||||
onMouseLeave={() => setActiveLink("home")}
|
||||
className="relative flex items-center gap-2 rounded-md px-4 py-2 font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>demo</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/docs"
|
||||
ref={(ref) => {
|
||||
linkRefs.current.docs = ref;
|
||||
}}
|
||||
onMouseOver={() => setActiveLink("docs")}
|
||||
onMouseLeave={() => setActiveLink("home")}
|
||||
className="relative flex items-center gap-2 rounded-md px-4 py-2 font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>docs</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://www.npmjs.com/package/create-better-t-stack"
|
||||
target="_blank"
|
||||
ref={(ref) => {
|
||||
linkRefs.current.npm = ref;
|
||||
}}
|
||||
onMouseOver={() => setActiveLink("npm")}
|
||||
onMouseLeave={() => setActiveLink("home")}
|
||||
className="relative flex items-center gap-2 rounded-md px-4 py-2 font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<PackageIcon pm="npm" className="h-4 w-4 rounded-full" />{" "}
|
||||
<span>npm</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://www.github.com/better-t-stack/create-better-t-stack"
|
||||
target="_blank"
|
||||
ref={(ref) => {
|
||||
linkRefs.current.github = ref;
|
||||
}}
|
||||
onMouseOver={() => setActiveLink("github")}
|
||||
onMouseLeave={() => setActiveLink("home")}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2 rounded-md px-4 py-2 font-mono text-muted-foreground transition-colors hover:text-primary",
|
||||
scrolled
|
||||
? "translate-y-0 opacity-100"
|
||||
: "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Github className="size-4">
|
||||
<title>GitHub</title>
|
||||
</Github>
|
||||
Github
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"hidden justify-end gap-2 transition-opacity duration-300 md:flex",
|
||||
scrolled ? "pointer-events-none opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/new"
|
||||
className="inline-flex items-center rounded-lg border border-primary/50 bg-primary/10 px-4 py-1 font-mono text-primary text-sm backdrop-blur-sm transition-colors hover:bg-primary/20"
|
||||
>
|
||||
<Maximize2 className="mr-1 size-4" />
|
||||
Stack Builder
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.github.com/better-t-stack/create-better-t-stack"
|
||||
target="_blank"
|
||||
className="inline-flex items-center rounded-lg border border-border bg-muted/90 px-4 py-1 font-mono text-muted-foreground text-sm backdrop-blur-sm transition-colors hover:bg-muted hover:text-primary"
|
||||
>
|
||||
<Github className="mr-1 size-4">
|
||||
<title>GitHub</title>
|
||||
</Github>
|
||||
Star on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMobileMenu}
|
||||
className="flex items-center justify-center rounded-md p-2 text-foreground hover:bg-muted/50 focus:outline-none md:hidden"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="size-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Menu className="size-5" aria-hidden="true" />
|
||||
)}
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99] pt-16 backdrop-blur-md transition-all duration-300 ease-in-out md:hidden",
|
||||
mobileMenuOpen
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="mx-4 mt-4 overflow-hidden rounded-lg border border-border bg-background/95">
|
||||
<div className="flex items-center bg-muted px-4 py-2">
|
||||
<div className="mr-4 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-yellow-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="font-mono text-muted-foreground text-sm">
|
||||
better-t-stack:~
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 font-mono text-sm">
|
||||
<div className="pb-3">
|
||||
<span className="text-[--color-chart-4]">
|
||||
user@better-t-stack
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-primary/50 bg-primary/10">
|
||||
<span className="font-medium font-mono text-primary text-sm">
|
||||
$_
|
||||
</span>
|
||||
<span className="text-muted-foreground">:~$</span>
|
||||
<span className="ml-2 text-foreground">ls -la</span>
|
||||
</div>
|
||||
<span className="hidden font-semibold text-foreground text-md sm:inline-block">
|
||||
Better-T Stack
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-4 md:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{desktopNavLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="relative flex items-center gap-1.5 rounded-md px-3 py-1.5 font-mono text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-primary"
|
||||
>
|
||||
{link.icon}
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-border border-l-2 pl-4">
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="block text-primary hover:underline"
|
||||
onClick={() => 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
|
||||
<Maximize2 className="size-3.5" />
|
||||
Builder
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://my-better-t-app-client.pages.dev/"
|
||||
target="_blank"
|
||||
className="block text-primary hover:underline"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
~/demo
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center">
|
||||
<PackageIcon pm="npm" className="mr-1 h-4 w-4" />
|
||||
<Link
|
||||
href="https://www.npmjs.com/package/create-better-t-stack"
|
||||
target="_blank"
|
||||
className="block text-primary hover:underline"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
~/npm
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<BookMarked className="mr-1 h-4 w-4" />
|
||||
<Link
|
||||
href="/docs"
|
||||
className="block text-primary hover:underline"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
~/docs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Github className="mr-1 size-4 text-foreground" />
|
||||
<Link
|
||||
href="https://www.github.com/better-t-stack/create-better-t-stack"
|
||||
target="_blank"
|
||||
className="block text-primary hover:underline"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
~/github
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pb-3">
|
||||
<span className="text-[--color-chart-4]">
|
||||
user@better-t-stack
|
||||
</span>
|
||||
<span className="text-muted-foreground">:~$</span>
|
||||
<span className="ml-2 text-foreground">star-repo</span>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-l-2 pb-2 pl-4">
|
||||
<Link
|
||||
href="https://www.github.com/better-t-stack/create-better-t-stack"
|
||||
target="_blank"
|
||||
className="inline-flex items-center rounded-md bg-muted px-4 py-2 text-foreground transition-colors hover:bg-muted/80"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Github className="mr-1 size-5" />
|
||||
Star on GitHub
|
||||
<Github className="size-3.5" />
|
||||
Star
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<span className="text-[--color-chart-4]">
|
||||
user@better-t-stack
|
||||
</span>
|
||||
<span className="text-muted-foreground">:~$</span>
|
||||
<span className="ml-2 animate-pulse text-foreground">█</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-foreground transition-colors hover:bg-muted"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="size-5" />
|
||||
) : (
|
||||
<Menu className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="fixed inset-0 z-[98] bg-background/50 backdrop-blur-sm md:hidden"
|
||||
onClick={closeMobileMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed top-0 right-0 bottom-0 z-[99] h-full w-full max-w-xs overflow-y-auto border-border border-l bg-background shadow-lg md:hidden"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex h-16 items-center justify-between border-border border-b px-4">
|
||||
<span className="font-semibold text-foreground text-md">
|
||||
Navigation
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeMobileMenu}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col p-4">
|
||||
<nav className="flex flex-col space-y-1">
|
||||
{mobileNavLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
onClick={closeMobileMenu}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-3 font-mono text-base text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
>
|
||||
{link.icon ? (
|
||||
<span className="flex w-5 items-center justify-center">
|
||||
{link.icon}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-5" />
|
||||
)}
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 space-y-3 border-border border-t pt-6">
|
||||
<Link
|
||||
href="/new"
|
||||
onClick={closeMobileMenu}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-primary/50 bg-primary/10 px-4 py-2.5 font-mono text-primary text-sm transition-colors hover:bg-primary/20"
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
Stack Builder
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.github.com/better-t-stack/create-better-t-stack"
|
||||
target="_blank"
|
||||
onClick={closeMobileMenu}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-muted/90 px-4 py-2.5 font-mono text-muted-foreground text-sm backdrop-blur-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Github className="size-4" />
|
||||
Star on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
}
|
||||
|
||||
59
apps/web/src/app/(home)/_components/SponsorsSection.tsx
Normal file
59
apps/web/src/app/(home)/_components/SponsorsSection.tsx
Normal file
@@ -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 (
|
||||
<motion.section
|
||||
className="relative z-10 mx-auto w-full max-w-7xl space-y-12 px-4 py-16 sm:px-6 sm:py-24 lg:space-y-16 lg:px-8"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={sectionVariants}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="font-bold font-mono text-3xl text-foreground tracking-tight sm:text-4xl lg:text-5xl">
|
||||
<span className="text-primary">Sponsors</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="flex justify-center"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Image
|
||||
src="https://cdn.jsdelivr.net/gh/amanvarshney01/sponsors@master/sponsorkit/sponsors.svg"
|
||||
alt="Sponsors"
|
||||
width={1000}
|
||||
height={500}
|
||||
className="h-auto max-w-full"
|
||||
style={{ colorScheme: "light" }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="https://github.com/sponsors/AmanVarshney01"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-primary/50 bg-primary/10 px-4 py-2 font-mono text-primary text-sm transition-colors hover:bg-primary/20"
|
||||
>
|
||||
Become a Sponsor
|
||||
</a>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<section className="relative z-10 mx-auto mt-12 w-full max-w-7xl space-y-8 px-4 sm:mt-20 sm:space-y-16 sm:px-6">
|
||||
<div className="relative space-y-4 text-center sm:space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative"
|
||||
>
|
||||
<h2 className="font-bold font-mono text-xl tracking-tight sm:text-2xl md:text-3xl lg:text-4xl xl:text-5xl">
|
||||
<span className="border-primary border-b-2 pb-1 text-foreground dark:text-primary">
|
||||
Developer Feedback
|
||||
</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mx-auto max-w-3xl font-mono text-base text-muted-foreground leading-relaxed sm:text-lg md:text-xl"
|
||||
>
|
||||
what devs are saying about Better-T-Stack
|
||||
</motion.p>
|
||||
<motion.section
|
||||
className="relative z-10 mx-auto w-full max-w-7xl space-y-12 px-4 py-16 sm:px-6 sm:py-24 lg:space-y-16 lg:px-8"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={sectionVariants}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="font-bold font-mono text-3xl text-foreground tracking-tight sm:text-4xl lg:text-5xl">
|
||||
Loved by <span className="text-primary">Developers</span>
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl font-mono text-lg text-muted-foreground leading-relaxed">
|
||||
See what people are saying about Better-T-Stack on X.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="relative mt-4 sm:mt-8"
|
||||
className={cn("grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3")}
|
||||
variants={gridVariants}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-background">
|
||||
<div className="flex items-center justify-between bg-muted px-2 py-2 sm:px-4">
|
||||
<div className="flex space-x-1 sm:space-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500 sm:h-3 sm:w-3" />
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500 sm:h-3 sm:w-3" />
|
||||
<div className="h-2 w-2 rounded-full bg-green-500 sm:h-3 sm:w-3" />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground sm:text-xs">
|
||||
Developer Feedback
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handlePrev}
|
||||
className="flex h-5 w-5 items-center justify-center rounded bg-secondary text-secondary-foreground transition-colors hover:bg-muted sm:h-6 sm:w-6"
|
||||
title="Previous testimonials"
|
||||
aria-label="Previous testimonials"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleNext}
|
||||
className="flex h-5 w-5 items-center justify-center rounded bg-secondary text-secondary-foreground transition-colors hover:bg-muted sm:h-6 sm:w-6"
|
||||
title="Next testimonials"
|
||||
aria-label="Next testimonials"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</motion.button>
|
||||
</div>
|
||||
{visibleTweetIndices.map((index) => (
|
||||
<div
|
||||
key={TWEET_IDS[index]}
|
||||
className="overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-300 hover:shadow-md"
|
||||
>
|
||||
<Tweet id={TWEET_IDS[index]} />
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTweets.map((tweetIndex) => (
|
||||
<Tweet key={tweetIndex} id={TWEET_IDS[tweetIndex]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-border border-t bg-muted px-2 py-2 sm:p-3">
|
||||
<div className="flex items-center">
|
||||
<span className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{currentPage}/{totalPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{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 (
|
||||
<button
|
||||
type="button"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={i}
|
||||
onClick={() => setStartIndex(pageNum * tweetsPerPage)}
|
||||
className={cn(
|
||||
"h-1 w-1 rounded-full transition-colors sm:h-1.5 sm:w-1.5",
|
||||
isActive
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/50 hover:bg-muted-foreground/70",
|
||||
)}
|
||||
aria-label={`Go to page ${pageNum + 1}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{totalPages > 5 && (
|
||||
<span className="text-[8px] text-muted-foreground sm:text-[10px]">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<motion.div
|
||||
className="mt-10 flex items-center justify-between sm:mt-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handlePrev}
|
||||
disabled={currentPage === 1}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Prev
|
||||
</motion.button>
|
||||
|
||||
<div className="hidden items-center gap-1 sm:flex">
|
||||
{paginationDots.map((page, index) =>
|
||||
typeof page === "number" ? (
|
||||
<button
|
||||
type="button"
|
||||
key={`${page}-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
index
|
||||
}`}
|
||||
onClick={() => goToPage(page)}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md font-medium text-sm transition-colors",
|
||||
currentPage === page
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
aria-label={`Go to page ${page}`}
|
||||
aria-current={currentPage === page ? "page" : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
key={`ellipsis-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
index
|
||||
}`}
|
||||
className="flex h-8 w-8 items-center justify-center text-muted-foreground text-sm"
|
||||
aria-hidden="true"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm sm:hidden">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="hidden w-1/3 items-center sm:flex">
|
||||
<div className="h-px flex-grow bg-gradient-to-r from-transparent via-primary/30 to-primary/50" />
|
||||
|
||||
<div className="h-2 w-2 rounded-full bg-primary/60" />
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -143,14 +142,11 @@ export default function HomePage() {
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden w-1/3 items-center sm:flex">
|
||||
<div className="h-2 w-2 rounded-full bg-primary/60" />
|
||||
|
||||
<div className="h-px flex-grow bg-gradient-to-l from-transparent via-primary/30 to-primary/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-px w-full bg-gradient-to-r from-transparent via-primary/30 to-transparent sm:hidden" />
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -164,6 +160,16 @@ export default function HomePage() {
|
||||
>
|
||||
<Testimonials />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="w-full"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.15 }}
|
||||
variants={sectionVariants}
|
||||
>
|
||||
<SponsorsSection />
|
||||
</motion.div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.react-tweet-theme {
|
||||
--tweet-container-margin: 0 !important;
|
||||
@apply !bg-background;
|
||||
@apply !bg-background !border-none !h-full !border-transparent;
|
||||
}
|
||||
|
||||
.shiny-text {
|
||||
|
||||
18
bun.lock
18
bun.lock
@@ -8,13 +8,13 @@
|
||||
"@changesets/cli": "^2.29.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.1",
|
||||
"turbo": "^2.5.1",
|
||||
"turbo": "^2.5.2",
|
||||
"typescript": "5.7.3",
|
||||
},
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "create-better-t-stack",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.1",
|
||||
"bin": {
|
||||
"create-better-t-stack": "dist/index.js",
|
||||
},
|
||||
@@ -1586,19 +1586,19 @@
|
||||
|
||||
"tsup": ["tsup@8.4.0", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.25.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "0.8.0-beta.0", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ=="],
|
||||
|
||||
"turbo": ["turbo@2.5.1", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.1", "turbo-darwin-arm64": "2.5.1", "turbo-linux-64": "2.5.1", "turbo-linux-arm64": "2.5.1", "turbo-windows-64": "2.5.1", "turbo-windows-arm64": "2.5.1" }, "bin": { "turbo": "bin/turbo" } }, "sha512-LT0wYyT+HY4StvmGMq1k2tHCIwauaWSXwyP+tCUked9vja5xEisW8b8NIJGi9BWH5HYH9Og1DysaQFTf8BiydQ=="],
|
||||
"turbo": ["turbo@2.5.2", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.2", "turbo-darwin-arm64": "2.5.2", "turbo-linux-64": "2.5.2", "turbo-linux-arm64": "2.5.2", "turbo-windows-64": "2.5.2", "turbo-windows-arm64": "2.5.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Qo5lfuStr6LQh3sPQl7kIi243bGU4aHGDQJUf6ylAdGwks30jJFloc9NYHP7Y373+gGU9OS0faA4Mb5Sy8X9Xw=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-U9lT1rZ20PQjEYDiNE0aZrU6K+StAE8rood9xn3pV1w+CSby56HkdR2AffzMdFf8iPTeZfcY1qL62rDcCeRPTw=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-2aIl0Sx230nLk+Cg2qSVxvPOBWCZpwKNuAMKoROTvWKif6VMpkWWiR9XEPoz7sHeLmCOed4GYGMjL1bqAiIS/g=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Mp0LeP9JENqHnurGNyD557sndPt2BYUbgzUX87tYIdu/26dHyqlobiRzPpEfkOGB/sV4exhJUJGXB1h72szLQ=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MrFYhK/jYu8N6QlqZtqSHi3e4QVxlzqU3ANHTKn3/tThuwTLbNHEvzBPWSj5W7nZcM58dCqi6gYrfRz6bJZyAA=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Cl2yKumJQAlNG5UA7vjCU6SPBLrcKaGhOjTaUjGHeD9WLL8vh4FwOlhOD2wk7zCUlhpJaM73WHY+oOZGMqmzOg=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-LxNqUE2HmAJQ/8deoLgMUDzKxd5bKxqH0UBogWa+DF+JcXhtze3UTMr6lEr0dEofdsEUYK1zg8FRjglmwlN5YA=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OFpb/9YZJG8v3nttD4K5dxW3bwsZp++oxAykpYsPhp552EX6r+dJrt2dzX3C0azls2JLf/UzTpA83fRoM8mC4g=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-0MI1Ao1q8zhd+UUbIEsrM+yLq1BsrcJQRGZkxIsHFlGp7WQQH1oR3laBgfnUCNdCotCMD6w4moc9pUbXdOR3bg=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6XnfSxE8xPETVAlAwfMqCuVuZbq9gXTj8H/Eggv/i3Tjoh2l5xMVTOmg3/zV4RlDtTcwhnvXgXx8LEXrSRZmQQ=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-hOLcbgZzE5ttACHHyc1ajmWYq4zKT42IC3G6XqgiXxMbS+4eyVYTL+7UvCZBd3Kca1u4TLQdLQjeO76zyDJc2A=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Nc9abxTCpRL8ejzzIm5j6jze3jFi23ZtU83Fwz2N9StquYHGEi72isyeCkrhzCiUvZZEPlFyFaXOSShcJUK58Q=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-fMU41ABhSLa18H8V3Z7BMCGynQ8x+wj9WyBMvWm1jeyRKgkvUYJsO2vkIsy8m0vrwnIeVXKOIn6eSe1ddlBVqw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.2.8", "", {}, "sha512-AxSnYRvyFnAiZCUndS3zQZhNfV/B77ZhJ+O7d3K6wfg/jKJY+yv6ahuyXwnyaYA9UdLqnpCwhTRv9pPTBnPR2g=="],
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@changesets/cli": "^2.29.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.1",
|
||||
"turbo": "^2.5.1",
|
||||
"turbo": "^2.5.2",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
Reference in New Issue
Block a user