feat(cli): add alchemy and improve cli tooling and structure (#520)

This commit is contained in:
Aman Varshney
2025-08-20 23:43:58 +05:30
committed by GitHub
parent c5430ae4fd
commit 5788876c47
152 changed files with 5804 additions and 2264 deletions

View File

@@ -26,7 +26,7 @@ export default function CustomizableSection() {
transition={{ duration: 0.5, delay: 0.2 }}
className="mx-auto max-w-3xl space-y-6"
>
<p className=" text-lg text-muted-foreground leading-relaxed sm:text-xl">
<p className="text-lg text-muted-foreground leading-relaxed sm:text-xl">
Build your perfect TypeScript stack.
</p>
</motion.div>

View File

@@ -98,7 +98,7 @@ export default function Navbar() {
className={cn(
"fixed top-0 z-[100] w-full transition-all duration-300 ease-in-out",
scrolled
? " border- border-border shadow-sm backdrop-blur-md"
? "border- border-border shadow-sm backdrop-blur-md"
: "border-transparent border-b bg-transparent",
)}
>
@@ -183,7 +183,7 @@ export default function Navbar() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className=" fixed inset-0 z-[98 backdrop-blur-sm lg:hidden"
className="fixed inset-0 z-[98 backdrop-blur-sm lg:hidden"
onClick={closeMobileMenu}
aria-hidden="true"
/>

View File

@@ -1,3 +1,5 @@
import { api } from "@better-t-stack/backend/convex/_generated/api";
import { useQueryWithStatus } from "@better-t-stack/backend/convex/hooks";
import {
ChevronDown,
ChevronUp,
@@ -8,12 +10,10 @@ import {
Terminal,
} from "lucide-react";
import Image from "next/image";
// import Link from "next/link";
import { useEffect, useState } from "react";
import { useState } from "react";
import {
filterCurrentSponsors,
filterPastSponsors,
filterRegularSponsors,
filterSpecialSponsors,
formatSponsorUrl,
getSponsorUrl,
@@ -21,39 +21,91 @@ import {
sortSpecialSponsors,
sortSponsors,
} from "@/lib/sponsor-utils";
import type { Sponsor } from "@/lib/types";
export default function SponsorsSection() {
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
const [loadingSponsors, setLoadingSponsors] = useState(true);
const [sponsorError, setSponsorError] = useState<string | null>(null);
const [showPastSponsors, setShowPastSponsors] = useState(false);
useEffect(() => {
fetch("https://sponsors.amanv.dev/sponsors.json")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch sponsors");
return res.json();
})
.then((data) => {
const sponsorsData = Array.isArray(data) ? data : [];
const sortedSponsors = sortSponsors(sponsorsData);
setSponsors(sortedSponsors);
setLoadingSponsors(false);
})
.catch(() => {
setSponsorError("Could not load sponsors");
setLoadingSponsors(false);
});
}, []);
const sponsorsQuery = useQueryWithStatus(api.sponsors.getSponsors);
const currentSponsors = filterCurrentSponsors(sponsors);
const pastSponsors = filterPastSponsors(sponsors);
if (sponsorsQuery.isPending) {
return (
<div className="mb-12">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
SPONSORS_DATABASE.JSON
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
[LOADING... RECORDS]
</span>
</div>
</div>
<div className="rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-muted-foreground">LOADING_SPONSORS.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
</div>
);
}
if (sponsorsQuery.isError) {
return (
<div className="mb-12">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
SPONSORS_DATABASE.JSON
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
[ERROR RECORDS]
</span>
</div>
</div>
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className="text-destructive">
ERROR_LOADING_SPONSORS.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Please try again later!
</span>
</div>
</div>
</div>
</div>
);
}
const sponsors =
sponsorsQuery.data?.map((sponsor) => ({
...sponsor,
sponsor: {
...sponsor.sponsor,
customLogoUrl: sponsor.sponsor.customLogoUrl || "",
},
})) || [];
const sortedSponsors = sortSponsors(sponsors);
const currentSponsors = filterCurrentSponsors(sortedSponsors);
const pastSponsors = filterPastSponsors(sortedSponsors);
const specialSponsors = sortSpecialSponsors(
filterSpecialSponsors(currentSponsors),
);
const regularSponsors = filterRegularSponsors(currentSponsors);
return (
<div className="mb-12">
@@ -65,37 +117,24 @@ export default function SponsorsSection() {
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[{loadingSponsors ? "LOADING..." : sponsors.length} RECORDS]
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
[{sponsors.length} RECORDS]
</span>
</div>
</div>
{loadingSponsors ? (
<div className="rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className=" text-muted-foreground">LOADING_SPONSORS.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
) : sponsorError ? (
<div className="rounded border border-border bg-destructive/10 p-8">
<div className="flex items-center justify-center gap-2">
<span className="text-destructive"></span>
<span className=" text-destructive">ERROR: {sponsorError}</span>
</div>
</div>
) : sponsors.length === 0 ? (
{sponsors.length === 0 ? (
<div className="space-y-4">
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className=" text-muted-foreground">
<span className="text-muted-foreground">
NO_SPONSORS_FOUND.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className=" text-muted-foreground">
<span className="text-muted-foreground">
Be the first to support this project!
</span>
</div>
@@ -162,7 +201,7 @@ export default function SponsorsSection() {
{entry.sponsor.name || entry.sponsor.login}
</h3>
{entry.tierName && (
<p className=" text-primary text-xs">
<p className="text-primary text-xs">
{entry.tierName}
</p>
)}
@@ -203,90 +242,93 @@ export default function SponsorsSection() {
</div>
</div>
)}
{regularSponsors.length > 0 && (
{currentSponsors.filter((s) => !isSpecialSponsor(s)).length > 0 && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{regularSponsors.map((entry, index) => {
const since = new Date(entry.createdAt).toLocaleDateString(
undefined,
{ year: "numeric", month: "short" },
);
return (
<div
key={entry.sponsor.login}
className="rounded border border-border"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="border-border border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-primary text-xs"></span>
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
<span>SINCE {since.toUpperCase()}</span>
{currentSponsors
.filter((s) => !isSpecialSponsor(s))
.map((entry, index) => {
const since = new Date(entry.createdAt).toLocaleDateString(
undefined,
{ year: "numeric", month: "short" },
);
return (
<div
key={entry.sponsor.login}
className="rounded border border-border"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="border-border border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-primary text-xs"></span>
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
<span>SINCE {since.toUpperCase()}</span>
</div>
</div>
</div>
</div>
<div className="p-4">
<div className="flex gap-4">
<div className="flex-shrink-0">
<Image
src={entry.sponsor.avatarUrl}
alt={entry.sponsor.name || entry.sponsor.login}
width={100}
height={100}
className="rounded border border-border transition-colors duration-300"
unoptimized
/>
</div>
<div className="grid grid-cols-1 grid-rows-[1fr_auto] justify-between py-2">
<div>
<h3 className="truncate font-semibold text-foreground text-sm">
{entry.sponsor.name || entry.sponsor.login}
</h3>
{entry.tierName && (
<p className=" text-primary text-xs">
{entry.tierName}
</p>
)}
<div className="p-4">
<div className="flex gap-4">
<div className="flex-shrink-0">
<Image
src={entry.sponsor.avatarUrl}
alt={entry.sponsor.name || entry.sponsor.login}
width={100}
height={100}
className="rounded border border-border transition-colors duration-300"
unoptimized
/>
</div>
<div className="flex flex-col gap-1">
<a
href={`https://github.com/${entry.sponsor.login}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
>
<Github className="h-4 w-4" />
<span className="truncate">
{entry.sponsor.login}
</span>
</a>
{(entry.sponsor.websiteUrl ||
entry.sponsor.linkUrl) && (
<div className="grid grid-cols-1 grid-rows-[1fr_auto] justify-between py-2">
<div>
<h3 className="truncate font-semibold text-foreground text-sm">
{entry.sponsor.name || entry.sponsor.login}
</h3>
{entry.tierName && (
<p className="text-primary text-xs">
{entry.tierName}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<a
href={
entry.sponsor.websiteUrl ||
entry.sponsor.linkUrl
}
href={`https://github.com/${entry.sponsor.login}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
>
<Globe className="h-4 w-4" />
<Github className="h-4 w-4" />
<span className="truncate">
{formatSponsorUrl(
entry.sponsor.websiteUrl ||
entry.sponsor.linkUrl,
)}
{entry.sponsor.login}
</span>
</a>
)}
{(entry.sponsor.websiteUrl ||
entry.sponsor.linkUrl) && (
<a
href={
entry.sponsor.websiteUrl ||
entry.sponsor.linkUrl
}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
>
<Globe className="h-4 w-4" />
<span className="truncate">
{formatSponsorUrl(
entry.sponsor.websiteUrl ||
entry.sponsor.linkUrl,
)}
</span>
</a>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
)}

View File

@@ -70,6 +70,7 @@ const CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [
"orm",
"dbSetup",
"webDeploy",
"serverDeploy",
"auth",
"packageManager",
"addons",
@@ -122,6 +123,7 @@ const getBadgeColors = (category: string): string => {
return "border-orange-300 bg-orange-100 text-orange-800 dark:border-orange-700/30 dark:bg-orange-900/30 dark:text-orange-300";
case "git":
case "webDeploy":
case "serverDeploy":
case "install":
return "border-gray-300 bg-gray-100 text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400";
default:
@@ -716,10 +718,10 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
if (!isPWACompat && nextStack.addons.includes("pwa")) {
incompatibleAddons.push("pwa");
notes.webFrontend.notes.push(
"PWA addon requires TanStack/React Router or Solid. Addon will be removed.",
"PWA addon requires TanStack Router, React Router, Solid, or Next.js. Addon will be removed.",
);
notes.addons.notes.push(
"PWA requires TanStack/React Router/Solid. It will be removed.",
"PWA requires TanStack Router, React Router, Solid, or Next.js. It will be removed.",
);
notes.webFrontend.hasIssue = true;
notes.addons.hasIssue = true;
@@ -731,10 +733,10 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
if (!isTauriCompat && nextStack.addons.includes("tauri")) {
incompatibleAddons.push("tauri");
notes.webFrontend.notes.push(
"Tauri addon requires TanStack/React Router, Nuxt, Svelte, Solid, or Next.js. Addon will be removed.",
"Tauri addon requires TanStack Router, React Router, Nuxt, Svelte, Solid, or Next.js. Addon will be removed.",
);
notes.addons.notes.push(
"Tauri requires TanStack/React Router/Nuxt/Svelte/Solid/Next.js. It will be removed.",
"Tauri requires TanStack Router, React Router, Nuxt, Svelte, Solid, or Next.js. It will be removed.",
);
notes.webFrontend.hasIssue = true;
notes.addons.hasIssue = true;
@@ -881,6 +883,160 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
});
}
// Server deployment requires a backend (and not Convex)
if (
nextStack.serverDeploy !== "none" &&
(nextStack.backend === "none" || nextStack.backend === "convex")
) {
notes.serverDeploy.notes.push(
"Server deployment requires a supported backend. It will be disabled.",
);
notes.backend.notes.push(
"No compatible backend selected: Server deployment has been disabled.",
);
notes.serverDeploy.hasIssue = true;
notes.backend.hasIssue = true;
nextStack.serverDeploy = "none";
changed = true;
changes.push({
category: "serverDeploy",
message: "Server deployment set to 'none' (requires backend)",
});
}
// Cloudflare server deployments (wrangler/alchemy) require Workers runtime
if (nextStack.serverDeploy !== "none" && nextStack.runtime !== "workers") {
notes.serverDeploy.notes.push(
"Selected server deployment targets Cloudflare Workers. Runtime will be set to 'Cloudflare Workers'.",
);
notes.runtime.notes.push(
"Server deployment requires Cloudflare Workers runtime. It will be selected.",
);
notes.serverDeploy.hasIssue = true;
notes.runtime.hasIssue = true;
nextStack.runtime = "workers";
changed = true;
changes.push({
category: "serverDeploy",
message:
"Runtime set to 'Cloudflare Workers' (required by server deployment)",
});
// Apply Workers runtime compatibility adjustments
if (nextStack.backend !== "hono") {
notes.runtime.notes.push(
"Cloudflare Workers runtime requires Hono backend. Hono will be selected.",
);
notes.backend.notes.push(
"Cloudflare Workers runtime requires Hono backend. It will be selected.",
);
notes.runtime.hasIssue = true;
notes.backend.hasIssue = true;
nextStack.backend = "hono";
changes.push({
category: "runtime",
message: "Backend set to 'Hono' (required by Cloudflare Workers)",
});
}
if (nextStack.orm !== "drizzle" && nextStack.orm !== "none") {
notes.runtime.notes.push(
"Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.",
);
notes.orm.notes.push(
"Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.",
);
notes.runtime.hasIssue = true;
notes.orm.hasIssue = true;
nextStack.orm = "drizzle";
changes.push({
category: "runtime",
message: "ORM set to 'Drizzle' (required by Cloudflare Workers)",
});
}
if (nextStack.database === "mongodb") {
notes.runtime.notes.push(
"Cloudflare Workers runtime is not compatible with MongoDB. SQLite will be selected.",
);
notes.database.notes.push(
"MongoDB is not compatible with Cloudflare Workers runtime. SQLite will be selected.",
);
notes.runtime.hasIssue = true;
notes.database.hasIssue = true;
nextStack.database = "sqlite";
changes.push({
category: "runtime",
message:
"Database set to 'SQLite' (MongoDB not compatible with Workers)",
});
}
if (nextStack.dbSetup === "docker") {
notes.runtime.notes.push(
"Cloudflare Workers runtime does not support Docker setup. D1 will be selected.",
);
notes.dbSetup.notes.push(
"Docker setup is not compatible with Cloudflare Workers runtime. D1 will be selected.",
);
notes.runtime.hasIssue = true;
notes.dbSetup.hasIssue = true;
nextStack.dbSetup = "d1";
changes.push({
category: "runtime",
message: "DB Setup set to 'D1' (Docker not compatible with Workers)",
});
}
}
// Alchemy deployment validation - temporarily not compatible with Next.js and React Router
const isAlchemyWebDeploy = nextStack.webDeploy === "alchemy";
const isAlchemyServerDeploy = nextStack.serverDeploy === "alchemy";
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
const incompatibleFrontends = nextStack.webFrontend.filter(
(f) => f === "next" || f === "react-router",
);
if (incompatibleFrontends.length > 0) {
const deployType =
isAlchemyWebDeploy && isAlchemyServerDeploy
? "web and server deployment"
: isAlchemyWebDeploy
? "web deployment"
: "server deployment";
notes.webFrontend.notes.push(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}. These frontends will be removed.`,
);
notes.webDeploy.notes.push(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`,
);
notes.serverDeploy.notes.push(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`,
);
notes.webFrontend.hasIssue = true;
notes.webDeploy.hasIssue = true;
notes.serverDeploy.hasIssue = true;
// Remove incompatible frontends
nextStack.webFrontend = nextStack.webFrontend.filter(
(f) => f !== "next" && f !== "react-router",
);
// If no web frontends remain, set to default
if (nextStack.webFrontend.length === 0) {
nextStack.webFrontend = ["tanstack-router"];
}
changed = true;
changes.push({
category: "alchemy",
message: `Removed ${incompatibleFrontends.join(" and ")} (not compatible with Alchemy ${deployType})`,
});
}
}
return {
adjustedStack: changed ? nextStack : null,
notes,
@@ -985,6 +1141,13 @@ const generateCommand = (stackState: StackState): string => {
flags.push(`--web-deploy ${stackState.webDeploy}`);
}
if (
stackState.serverDeploy &&
!checkDefault("serverDeploy", stackState.serverDeploy)
) {
flags.push(`--server-deploy ${stackState.serverDeploy}`);
}
if (!checkDefault("install", stackState.install)) {
if (stackState.install === "false" && DEFAULT_STACK.install === "true") {
flags.push("--no-install");
@@ -1461,6 +1624,19 @@ const StackBuilder = () => {
const { adjustedStack } = analyzeStackCompatibility(simulatedStack);
const finalStack = adjustedStack ?? simulatedStack;
// Additional check for Alchemy compatibility with Next.js and React Router
if (
category === "webFrontend" &&
(optionId === "next" || optionId === "react-router")
) {
const isAlchemyWebDeploy = finalStack.webDeploy === "alchemy";
const isAlchemyServerDeploy = finalStack.serverDeploy === "alchemy";
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
return false;
}
}
if (
category === "webFrontend" ||
category === "nativeFrontend" ||

View File

@@ -1,127 +1,13 @@
"use client";
import { api } from "@better-t-stack/backend/convex/_generated/api";
import { useQueryWithStatus } from "@better-t-stack/backend/convex/hooks";
import { Play, Terminal } from "lucide-react";
import { motion } from "motion/react";
import Image from "next/image";
import { Suspense } from "react";
import { Tweet, TweetSkeleton, type TwitterComponents } from "react-tweet";
const YOUTUBE_VIDEOS = [
{
embedId: "VL6zJH6z8wY",
title: "Advanced Vibe Coding - setup for new projects",
},
{
embedId: "cdivzGRhsYk",
title:
"MY UPGRADED AI Coding Workflow + Free APIs: How I DO AI Coding! (Stitch, Better T3, SuperNinja)",
},
{
embedId: "azhw_iq8SIA",
title: "This CLI Lets You Choose Your Entire Tech Stack Instantly",
},
{
embedId: "CWwkWJmT_zU",
title: "The BEST Way To Start a Project (Better-T-Stack)",
},
{
embedId: "MGmPTcgJYIo",
title: "This new CLI tool makes scaffolding projects easy",
},
{
embedId: "g-ynSAdL6Ak",
title: "This tool cured my JavaScript fatigue",
},
{
embedId: "uHUgw-Hi8HE",
title: "I tried React again after 2 years of Svelte",
},
];
const TWEET_IDS = [
"1930194170418999437",
"1907728148294447538",
"1936942642069455037",
"1931029815047455149",
"1933149770639614324",
"1937599252173128103",
"1947357370302304559",
"1930511724702285885",
"1945591420657532994",
"1945204056063913989",
"1912836377365905496",
"1947973299805561005",
"1949843350250738126",
"1949907407657992231",
"1907817662215757853",
"1933216760896934060",
"1949912886958301546",
"1942558041704182158",
"1947636576118304881",
"1951704580691304693",
"1937383786637094958",
"1931709370003583004",
"1929147326955704662",
"1948050877454938549",
"1951599045383770386",
"1904228496144269699",
"1949851365435469889",
"1950457707632214136",
"1930257410259616057",
"1937258706279817570",
"1917815700980391964",
"1949921211586400419",
"1947812547551498466",
"1928317790588403953",
"1917640304758514093",
"1951703990896570459",
"1907831059275735353",
"1912924558522524039",
"1945054982870282575",
"1933150129738981383",
"1949907577611145726",
"1911490975173607495",
"1930104047845158972",
"1913773945523953713",
"1951540684340469950",
"1944937093387706572",
"1904241046898556970",
"1913834145471672652",
"1946245671880966269",
"1930514202260635807",
"1931589579749892480",
"1904144343125860404",
"1917610656477348229",
"1904215768272654825",
"1931830211013718312",
"1944895251811893680",
"1913833079342522779",
"1930449311848087708",
"1942680754384953790",
"1907723601731530820",
"1944553262792810603",
"1904233896851521980",
"1930294868808515726",
"1943290033383047237",
"1913801258789491021",
"1907841646513005038",
"1904301540422070671",
"1944208789617471503",
"1912837026925195652",
"1904338606409531710",
"1942965795920679188",
"1904318186750652606",
"1943656585294643386",
"1908568583799484519",
"1913018977321693448",
"1904179661086556412",
"1908558365128876311",
"1907772878139072851",
"1906149740095705265",
"1906001923456790710",
"1906570888897777847",
];
export const components: TwitterComponents = {
AvatarImg: (props) => {
if (!props.src || props.src === "") {
@@ -143,15 +29,173 @@ export const components: TwitterComponents = {
},
};
const VideoCard = ({
video,
index,
}: {
video: { embedId: string; title: string };
index: number;
}) => (
<motion.div
className="w-full min-w-0"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
delay: index * 0.1,
duration: 0.4,
ease: "easeOut",
}}
>
<div className="w-full min-w-0 overflow-hidden rounded border border-border">
<div className="sticky top-0 z-10 border-border border-b px-2 py-2">
<div className="flex items-center gap-2">
<Play className="h-3 w-3 text-primary" />
<span className="font-semibold text-xs">
[VIDEO_{String(index + 1).padStart(3, "0")}]
</span>
</div>
</div>
<div className="w-full min-w-0 overflow-hidden">
<div className="relative aspect-video w-full">
<iframe
src={`https://www.youtube.com/embed/${video.embedId}`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 h-full w-full"
/>
</div>
</div>
</div>
</motion.div>
);
const TweetCard = ({ tweetId, index }: { tweetId: string; index: number }) => (
<motion.div
className="w-full min-w-0"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
delay: index * 0.05,
duration: 0.4,
ease: "easeOut",
}}
>
<div className="w-full min-w-0 overflow-hidden rounded border border-border">
<div className="sticky top-0 z-10 border-border border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-primary text-xs"></span>
<span className="font-semibold text-xs">
[TWEET_{String(index + 1).padStart(3, "0")}]
</span>
</div>
</div>
<div className="w-full min-w-0 overflow-hidden">
<div style={{ width: "100%", minWidth: 0, maxWidth: "100%" }}>
<Suspense fallback={<TweetSkeleton />}>
<Tweet id={tweetId} components={components} />
</Suspense>
</div>
</div>
</div>
</motion.div>
);
export default function Testimonials() {
const videosQuery = useQueryWithStatus(api.testimonials.getVideos);
const tweetsQuery = useQueryWithStatus(api.testimonials.getTweets);
const videos = videosQuery.data || [];
const tweets = tweetsQuery.data || [];
if (videosQuery.isPending || tweetsQuery.isPending) {
return (
<div className="mb-12 w-full max-w-full overflow-hidden px-4">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Play className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
VIDEO_TESTIMONIALS.LOG
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[LOADING... ENTRIES]
</span>
</div>
<div className="mb-6 rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-muted-foreground">LOADING_VIDEOS.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
DEVELOPER_TESTIMONIALS.LOG
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[LOADING... ENTRIES]
</span>
</div>
<div className="rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-muted-foreground">LOADING_TWEETS.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
</div>
);
}
if (videosQuery.isError || tweetsQuery.isError) {
return (
<div className="mb-12 w-full max-w-full overflow-hidden px-4">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Play className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
VIDEO_TESTIMONIALS.LOG
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[ERROR ENTRIES]
</span>
</div>
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className="text-destructive">
ERROR_LOADING_TESTIMONIALS.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Please try again later!
</span>
</div>
</div>
</div>
</div>
);
}
const getResponsiveColumns = (numCols: number) => {
const columns: string[][] = Array(numCols)
.fill(null)
.map(() => []);
TWEET_IDS.forEach((tweetId, index) => {
tweets.forEach((tweet, index) => {
const colIndex = index % numCols;
columns[colIndex].push(tweetId);
columns[colIndex].push(tweet.tweetId);
});
return columns;
@@ -173,84 +217,6 @@ export default function Testimonials() {
},
};
const VideoCard = ({
video,
index,
}: {
video: (typeof YOUTUBE_VIDEOS)[0];
index: number;
}) => (
<motion.div
className="w-full min-w-0"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
delay: index * 0.1,
duration: 0.4,
ease: "easeOut",
}}
>
<div className="w-full min-w-0 overflow-hidden rounded border border-border">
<div className="sticky top-0 z-10 border-border border-b px-2 py-2">
<div className="flex items-center gap-2">
<Play className="h-3 w-3 text-primary" />
<span className="font-semibold text-xs">
[VIDEO_{String(index + 1).padStart(3, "0")}]
</span>
</div>
</div>
<div className="w-full min-w-0 overflow-hidden">
<div className="relative aspect-video w-full">
<iframe
src={`https://www.youtube.com/embed/${video.embedId}`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 h-full w-full"
/>
</div>
</div>
</div>
</motion.div>
);
const TweetCard = ({
tweetId,
index,
}: {
tweetId: string;
index: number;
}) => (
<motion.div
className="w-full min-w-0"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
delay: index * 0.05,
duration: 0.4,
ease: "easeOut",
}}
>
<div className="w-full min-w-0 overflow-hidden rounded border border-border">
<div className="sticky top-0 z-10 border-border border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-primary text-xs"></span>
<span className="font-semibold text-xs">
[TWEET_{String(index + 1).padStart(3, "0")}]
</span>
</div>
</div>
<div className="w-full min-w-0 overflow-hidden">
<div style={{ width: "100%", minWidth: 0, maxWidth: "100%" }}>
<Suspense fallback={<TweetSkeleton />}>
<Tweet id={tweetId} components={components} />
</Suspense>
</div>
</div>
</div>
</motion.div>
);
return (
<div className="mb-12 w-full max-w-full overflow-hidden px-4">
<div className="mb-8">
@@ -263,7 +229,7 @@ export default function Testimonials() {
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[{YOUTUBE_VIDEOS.length} ENTRIES]
[{videos.length} ENTRIES]
</span>
</div>
@@ -274,7 +240,7 @@ export default function Testimonials() {
initial="hidden"
animate="visible"
>
{YOUTUBE_VIDEOS.map((video, index) => (
{videos.map((video, index) => (
<VideoCard
key={`video-${video.embedId}`}
video={video}
@@ -291,7 +257,7 @@ export default function Testimonials() {
initial="hidden"
animate="visible"
>
{YOUTUBE_VIDEOS.map((video, index) => (
{videos.map((video, index) => (
<VideoCard
key={`video-${video.embedId}`}
video={video}
@@ -311,7 +277,7 @@ export default function Testimonials() {
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[{TWEET_IDS.length} ENTRIES]
[{tweets.length} ENTRIES]
</span>
</div>
<div className="block sm:hidden">
@@ -321,8 +287,12 @@ export default function Testimonials() {
initial="hidden"
animate="visible"
>
{TWEET_IDS.map((tweetId, index) => (
<TweetCard key={tweetId} tweetId={tweetId} index={index} />
{tweets.map((tweet, index) => (
<TweetCard
key={tweet.tweetId}
tweetId={tweet.tweetId}
index={index}
/>
))}
</motion.div>
</div>
@@ -336,7 +306,7 @@ export default function Testimonials() {
>
{getResponsiveColumns(2).map((column, colIndex) => (
<motion.div
key={column.join("-")}
key={`col-2-${column.length > 0 ? column[0] : `empty-${colIndex}`}`}
className="flex min-w-0 flex-col gap-4"
variants={columnVariants}
>
@@ -364,7 +334,7 @@ export default function Testimonials() {
>
{getResponsiveColumns(3).map((column, colIndex) => (
<motion.div
key={column.join("-")}
key={`col-3-${column.length > 0 ? column[0] : `empty-${colIndex}`}`}
className="flex min-w-0 flex-col gap-4"
variants={columnVariants}
>