mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat(cli): add alchemy and improve cli tooling and structure (#520)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user