mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat(web): llms.txt, convex live stats, switch to vercel and improved ui (#460)
This commit is contained in:
1
apps/web/.vercelignore
Normal file
1
apps/web/.vercelignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
scripts/
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
|
||||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY || "", {
|
if (process.env.NODE_ENV !== "development") {
|
||||||
api_host: "/ingest",
|
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY || "", {
|
||||||
ui_host: "https://us.posthog.com",
|
api_host: "/ingest",
|
||||||
defaults: "2025-05-24",
|
ui_host: "https://us.posthog.com",
|
||||||
capture_exceptions: true, // This enables capturing exceptions using Error Tracking
|
defaults: "2025-05-24",
|
||||||
debug: process.env.NODE_ENV === "development",
|
capture_exceptions: true, // This enables capturing exceptions using Error Tracking
|
||||||
});
|
debug: process.env.NODE_ENV !== "production",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ const config = {
|
|||||||
source: "/ingest/decide",
|
source: "/ingest/decide",
|
||||||
destination: "https://us.i.posthog.com/decide",
|
destination: "https://us.i.posthog.com/decide",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/docs/:path*.mdx",
|
||||||
|
destination: "/llms.mdx/:path*",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withMDX(config);
|
export default withMDX(config);
|
||||||
|
|
||||||
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
|
|
||||||
|
|
||||||
initOpenNextCloudflareForDev();
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
|
||||||
|
|
||||||
export default defineCloudflareConfig();
|
|
||||||
@@ -8,49 +8,53 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"check": "biome check --write .",
|
"check": "biome check --write .",
|
||||||
"postinstall": "fumadocs-mdx",
|
"postinstall": "fumadocs-mdx",
|
||||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
"deploy": "vercel --prod",
|
||||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
"preview": "vercel dev",
|
||||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
|
||||||
"generate-analytics": "bun scripts/generate-analytics.ts",
|
"generate-analytics": "bun scripts/generate-analytics.ts",
|
||||||
"generate-schema": "bun scripts/generate-schema.ts"
|
"generate-schema": "bun scripts/generate-schema.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opennextjs/cloudflare": "^1.5.1",
|
"@better-t-stack/backend": "workspace:*",
|
||||||
|
"@erquhart/convex-oss-stats": "^0.8.1",
|
||||||
|
"@number-flow/react": "^0.5.10",
|
||||||
|
"@orama/orama": "^3.1.11",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"babel-plugin-react-compiler": "^19.1.0-rc.2",
|
"babel-plugin-react-compiler": "^19.1.0-rc.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"convex": "^1.25.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fumadocs-core": "15.6.3",
|
"fumadocs-core": "15.6.7",
|
||||||
"fumadocs-mdx": "11.6.11",
|
"fumadocs-mdx": "11.7.3",
|
||||||
"fumadocs-ui": "15.6.3",
|
"fumadocs-ui": "15.6.7",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.536.0",
|
||||||
"motion": "^12.23.3",
|
"motion": "^12.23.12",
|
||||||
"next": "15.3.5",
|
"next": "15.4.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nuqs": "^2.4.3",
|
"nuqs": "^2.4.3",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"posthog-js": "^1.257.0",
|
"posthog-js": "^1.258.5",
|
||||||
"radix-ui": "^1.4.2",
|
"radix-ui": "^1.4.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.1",
|
||||||
"react-tweet": "^3.2.2",
|
"react-tweet": "^3.2.2",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
|
"shiki": "^3.9.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "24.0.13",
|
"@types/node": "24.1.0",
|
||||||
"@types/papaparse": "^5.3.16",
|
"@types/papaparse": "^5.3.16",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.9",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.7",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.32.0",
|
||||||
"eslint-config-next": "15.3.5",
|
"eslint-config-next": "15.4.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.6",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.2"
|
||||||
"wrangler": "^4.24.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
apps/web/public/analytics-minimal.json
Normal file
6
apps/web/public/analytics-minimal.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"totalProjects": 14175,
|
||||||
|
"avgProjectsPerDay": "211.6",
|
||||||
|
"lastUpdated": "Aug 1, 2025, 03:44 AM",
|
||||||
|
"generatedAt": "2025-08-02T14:03:13.992Z"
|
||||||
|
}
|
||||||
@@ -1,43 +1,57 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import Papa from "papaparse";
|
import Papa from "papaparse";
|
||||||
|
|
||||||
// TODO: write a more effiecient way of handling analytics
|
|
||||||
|
|
||||||
interface AnalyticsData {
|
|
||||||
date: string;
|
|
||||||
hour: number;
|
|
||||||
cli_version: string;
|
|
||||||
node_version: string;
|
|
||||||
platform: string;
|
|
||||||
backend: string;
|
|
||||||
database: string;
|
|
||||||
orm: string;
|
|
||||||
dbSetup: string;
|
|
||||||
auth: string;
|
|
||||||
api: string;
|
|
||||||
packageManager: string;
|
|
||||||
frontend0: string;
|
|
||||||
frontend1: string;
|
|
||||||
examples0: string;
|
|
||||||
examples1: string;
|
|
||||||
addons: string[];
|
|
||||||
git: string;
|
|
||||||
install: string;
|
|
||||||
runtime: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CSVRow {
|
interface CSVRow {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessedAnalyticsData {
|
interface AggregatedAnalyticsData {
|
||||||
data: AnalyticsData[];
|
|
||||||
lastUpdated: string | null;
|
lastUpdated: string | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
totalRecords: number;
|
totalRecords: number;
|
||||||
|
timeSeries: Array<{ date: string; displayDate: string; count: number }>;
|
||||||
|
monthlyTimeSeries: Array<{
|
||||||
|
month: string;
|
||||||
|
displayMonth: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
platformDistribution: Array<{ name: string; value: number }>;
|
||||||
|
packageManagerDistribution: Array<{ name: string; value: number }>;
|
||||||
|
backendDistribution: Array<{ name: string; value: number }>;
|
||||||
|
databaseDistribution: Array<{ name: string; value: number }>;
|
||||||
|
ormDistribution: Array<{ name: string; value: number }>;
|
||||||
|
dbSetupDistribution: Array<{ name: string; value: number }>;
|
||||||
|
apiDistribution: Array<{ name: string; value: number }>;
|
||||||
|
frontendDistribution: Array<{ name: string; value: number }>;
|
||||||
|
nodeVersionDistribution: Array<{ version: string; count: number }>;
|
||||||
|
cliVersionDistribution: Array<{ version: string; count: number }>;
|
||||||
|
authDistribution: Array<{ name: string; value: number }>;
|
||||||
|
gitDistribution: Array<{ name: string; value: number }>;
|
||||||
|
installDistribution: Array<{ name: string; value: number }>;
|
||||||
|
examplesDistribution: Array<{ name: string; value: number }>;
|
||||||
|
addonsDistribution: Array<{ name: string; value: number }>;
|
||||||
|
runtimeDistribution: Array<{ name: string; value: number }>;
|
||||||
|
projectTypeDistribution: Array<{ name: string; value: number }>;
|
||||||
|
popularStackCombinations: Array<{ name: string; value: number }>;
|
||||||
|
databaseORMCombinations: Array<{ name: string; value: number }>;
|
||||||
|
hourlyDistribution: Array<{
|
||||||
|
hour: string;
|
||||||
|
displayHour: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
totalProjects: number;
|
||||||
|
avgProjectsPerDay: number;
|
||||||
|
authEnabledPercent: number;
|
||||||
|
mostPopularFrontend: string;
|
||||||
|
mostPopularBackend: string;
|
||||||
|
mostPopularORM: string;
|
||||||
|
mostPopularAPI: string;
|
||||||
|
mostPopularPackageManager: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateAnalyticsData() {
|
async function generateAnalyticsData() {
|
||||||
@@ -49,69 +63,193 @@ async function generateAnalyticsData() {
|
|||||||
|
|
||||||
console.log("📊 Processing CSV data...");
|
console.log("📊 Processing CSV data...");
|
||||||
|
|
||||||
let processedData: AnalyticsData[] = [];
|
let totalRecords = 0;
|
||||||
|
const dateCounts: Record<string, number> = {};
|
||||||
|
const monthlyCounts: Record<string, number> = {};
|
||||||
|
const platformCounts: Record<string, number> = {};
|
||||||
|
const packageManagerCounts: Record<string, number> = {};
|
||||||
|
const backendCounts: Record<string, number> = {};
|
||||||
|
const databaseCounts: Record<string, number> = {};
|
||||||
|
const ormCounts: Record<string, number> = {};
|
||||||
|
const dbSetupCounts: Record<string, number> = {};
|
||||||
|
const apiCounts: Record<string, number> = {};
|
||||||
|
const frontendCounts: Record<string, number> = {};
|
||||||
|
const nodeVersionCounts: Record<string, number> = {};
|
||||||
|
const cliVersionCounts: Record<string, number> = {};
|
||||||
|
const authCounts: Record<string, number> = {};
|
||||||
|
const gitCounts: Record<string, number> = {};
|
||||||
|
const installCounts: Record<string, number> = {};
|
||||||
|
const examplesCounts: Record<string, number> = {};
|
||||||
|
const addonsCounts: Record<string, number> = {};
|
||||||
|
const runtimeCounts: Record<string, number> = {};
|
||||||
|
const projectTypeCounts: Record<string, number> = {};
|
||||||
|
const stackComboCounts: Record<string, number> = {};
|
||||||
|
const dbORMComboCounts: Record<string, number> = {};
|
||||||
|
const hourlyCounts: Record<number, number> = {};
|
||||||
|
let authEnabledCount = 0;
|
||||||
|
|
||||||
Papa.parse<CSVRow>(csvText, {
|
Papa.parse<CSVRow>(csvText, {
|
||||||
header: true,
|
header: true,
|
||||||
complete: (results) => {
|
complete: (results) => {
|
||||||
try {
|
try {
|
||||||
processedData = results.data
|
results.data.forEach((row) => {
|
||||||
.map((row): AnalyticsData | null => {
|
const timestamp = row["*.timestamp"] || new Date().toISOString();
|
||||||
const timestamp = row["*.timestamp"] || new Date().toISOString();
|
const date = timestamp.includes("T")
|
||||||
const date = timestamp.includes("T")
|
? timestamp.split("T")[0]
|
||||||
? timestamp.split("T")[0]
|
: timestamp.split(" ")[0];
|
||||||
: timestamp.split(" ")[0];
|
|
||||||
|
|
||||||
let hour = 0;
|
// Skip invalid records
|
||||||
try {
|
if (!date || row["*.properties.platform"] === "unknown") {
|
||||||
const timestampDate = new Date(timestamp);
|
return;
|
||||||
if (!Number.isNaN(timestampDate.getTime())) {
|
}
|
||||||
hour = timestampDate.getUTCHours();
|
|
||||||
}
|
totalRecords++;
|
||||||
} catch {
|
|
||||||
hour = 0;
|
// Time series data
|
||||||
|
dateCounts[date] = (dateCounts[date] || 0) + 1;
|
||||||
|
|
||||||
|
const timestampDate = new Date(timestamp);
|
||||||
|
if (!Number.isNaN(timestampDate.getTime())) {
|
||||||
|
const monthKey = `${timestampDate.getFullYear()}-${String(timestampDate.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
monthlyCounts[monthKey] = (monthlyCounts[monthKey] || 0) + 1;
|
||||||
|
|
||||||
|
const hour = timestampDate.getUTCHours();
|
||||||
|
hourlyCounts[hour] = (hourlyCounts[hour] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform
|
||||||
|
const platform = row["*.properties.platform"] || "unknown";
|
||||||
|
platformCounts[platform] = (platformCounts[platform] || 0) + 1;
|
||||||
|
|
||||||
|
// Package manager
|
||||||
|
const pm = row["*.properties.packageManager"] || "unknown";
|
||||||
|
packageManagerCounts[pm] = (packageManagerCounts[pm] || 0) + 1;
|
||||||
|
|
||||||
|
// Backend
|
||||||
|
const backend = row["*.properties.backend"] || "none";
|
||||||
|
backendCounts[backend] = (backendCounts[backend] || 0) + 1;
|
||||||
|
|
||||||
|
// Database
|
||||||
|
const database = row["*.properties.database"] || "none";
|
||||||
|
databaseCounts[database] = (databaseCounts[database] || 0) + 1;
|
||||||
|
|
||||||
|
// ORM
|
||||||
|
const orm = row["*.properties.orm"] || "none";
|
||||||
|
ormCounts[orm] = (ormCounts[orm] || 0) + 1;
|
||||||
|
|
||||||
|
// DB Setup
|
||||||
|
const dbSetup = row["*.properties.dbSetup"] || "none";
|
||||||
|
dbSetupCounts[dbSetup] = (dbSetupCounts[dbSetup] || 0) + 1;
|
||||||
|
|
||||||
|
// API
|
||||||
|
const api = row["*.properties.api"] || "none";
|
||||||
|
apiCounts[api] = (apiCounts[api] || 0) + 1;
|
||||||
|
|
||||||
|
// Frontend
|
||||||
|
const frontend0 = row["*.properties.frontend.0"] || "";
|
||||||
|
const frontend1 = row["*.properties.frontend.1"] || "";
|
||||||
|
if (frontend0 && frontend0 !== "none" && frontend0 !== "") {
|
||||||
|
frontendCounts[frontend0] = (frontendCounts[frontend0] || 0) + 1;
|
||||||
|
}
|
||||||
|
if (frontend1 && frontend1 !== "none" && frontend1 !== "") {
|
||||||
|
frontendCounts[frontend1] = (frontendCounts[frontend1] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node version
|
||||||
|
const nodeVersion = row["*.properties.node_version"] || "unknown";
|
||||||
|
const majorVersion = nodeVersion.replace(/^v/, "").split(".")[0];
|
||||||
|
nodeVersionCounts[majorVersion] =
|
||||||
|
(nodeVersionCounts[majorVersion] || 0) + 1;
|
||||||
|
|
||||||
|
// CLI version
|
||||||
|
const cliVersion = row["*.properties.cli_version"] || "unknown";
|
||||||
|
cliVersionCounts[cliVersion] =
|
||||||
|
(cliVersionCounts[cliVersion] || 0) + 1;
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
const auth =
|
||||||
|
row["*.properties.auth"] === "True" ? "enabled" : "disabled";
|
||||||
|
authCounts[auth] = (authCounts[auth] || 0) + 1;
|
||||||
|
if (auth === "enabled") authEnabledCount++;
|
||||||
|
|
||||||
|
// Git
|
||||||
|
const git =
|
||||||
|
row["*.properties.git"] === "True" ? "enabled" : "disabled";
|
||||||
|
gitCounts[git] = (gitCounts[git] || 0) + 1;
|
||||||
|
|
||||||
|
// Install
|
||||||
|
const install =
|
||||||
|
row["*.properties.install"] === "True" ? "enabled" : "disabled";
|
||||||
|
installCounts[install] = (installCounts[install] || 0) + 1;
|
||||||
|
|
||||||
|
// Examples
|
||||||
|
const examples = [
|
||||||
|
row["*.properties.examples.0"],
|
||||||
|
row["*.properties.examples.1"],
|
||||||
|
].filter(Boolean);
|
||||||
|
if (examples.length === 0) {
|
||||||
|
examplesCounts["none"] = (examplesCounts["none"] || 0) + 1;
|
||||||
|
} else {
|
||||||
|
for (const example of examples) {
|
||||||
|
examplesCounts[example] = (examplesCounts[example] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addons = [
|
// Addons
|
||||||
row["*.properties.addons.0"],
|
const addons = [
|
||||||
row["*.properties.addons.1"],
|
row["*.properties.addons.0"],
|
||||||
row["*.properties.addons.2"],
|
row["*.properties.addons.1"],
|
||||||
row["*.properties.addons.3"],
|
row["*.properties.addons.2"],
|
||||||
row["*.properties.addons.4"],
|
row["*.properties.addons.3"],
|
||||||
row["*.properties.addons.5"],
|
row["*.properties.addons.4"],
|
||||||
].filter(Boolean);
|
row["*.properties.addons.5"],
|
||||||
|
].filter(Boolean);
|
||||||
|
if (addons.length === 0) {
|
||||||
|
addonsCounts["none"] = (addonsCounts["none"] || 0) + 1;
|
||||||
|
} else {
|
||||||
|
for (const addon of addons) {
|
||||||
|
addonsCounts[addon] = (addonsCounts[addon] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// Runtime
|
||||||
date,
|
const runtime = row["*.properties.runtime"] || "unknown";
|
||||||
hour,
|
runtimeCounts[runtime] = (runtimeCounts[runtime] || 0) + 1;
|
||||||
cli_version: row["*.properties.cli_version"] || "unknown",
|
|
||||||
node_version: row["*.properties.node_version"] || "unknown",
|
// Project type
|
||||||
platform: row["*.properties.platform"] || "unknown",
|
const hasFrontend =
|
||||||
backend: row["*.properties.backend"] || "none",
|
(frontend0 && frontend0 !== "none") ||
|
||||||
database: row["*.properties.database"] || "none",
|
(frontend1 && frontend1 !== "none");
|
||||||
orm: row["*.properties.orm"] || "none",
|
const hasBackend = backend && backend !== "none";
|
||||||
dbSetup: row["*.properties.dbSetup"] || "none",
|
let type: string;
|
||||||
auth:
|
if (hasFrontend && hasBackend) {
|
||||||
row["*.properties.auth"] === "True" ? "enabled" : "disabled",
|
type = "fullstack";
|
||||||
api: row["*.properties.api"] || "none",
|
} else if (hasFrontend && !hasBackend) {
|
||||||
packageManager: row["*.properties.packageManager"] || "unknown",
|
type = "frontend-only";
|
||||||
frontend0: row["*.properties.frontend.0"] || "",
|
} else if (!hasFrontend && hasBackend) {
|
||||||
frontend1: row["*.properties.frontend.1"] || "",
|
type = "backend-only";
|
||||||
examples0: row["*.properties.examples.0"] || "",
|
} else {
|
||||||
examples1: row["*.properties.examples.1"] || "",
|
type = "none";
|
||||||
addons,
|
}
|
||||||
git:
|
projectTypeCounts[type] = (projectTypeCounts[type] || 0) + 1;
|
||||||
row["*.properties.git"] === "True" ? "enabled" : "disabled",
|
|
||||||
install:
|
// Stack combinations
|
||||||
row["*.properties.install"] === "True"
|
const frontends = [frontend0, frontend1].filter(
|
||||||
? "enabled"
|
(f) => f && f !== "none" && f !== "",
|
||||||
: "disabled",
|
|
||||||
runtime: row["*.properties.runtime"] || "unknown",
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is AnalyticsData =>
|
|
||||||
Boolean(item?.date && item?.platform !== "unknown"),
|
|
||||||
);
|
);
|
||||||
|
const parts = [...frontends];
|
||||||
|
if (backend !== "none") {
|
||||||
|
parts.push(backend);
|
||||||
|
}
|
||||||
|
const combo = parts.length > 0 ? parts.join(" + ") : "none";
|
||||||
|
stackComboCounts[combo] = (stackComboCounts[combo] || 0) + 1;
|
||||||
|
|
||||||
|
// Database + ORM combinations
|
||||||
|
if (database !== "none" && orm !== "none") {
|
||||||
|
const combo = `${database} + ${orm}`;
|
||||||
|
dbORMComboCounts[combo] = (dbORMComboCounts[combo] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing CSV:", error);
|
console.error("Error parsing CSV:", error);
|
||||||
}
|
}
|
||||||
@@ -121,6 +259,7 @@ async function generateAnalyticsData() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get last updated timestamp
|
||||||
const lines = csvText.split("\n");
|
const lines = csvText.split("\n");
|
||||||
const timestampColumn = lines[0]
|
const timestampColumn = lines[0]
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -154,13 +293,175 @@ async function generateAnalyticsData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const analyticsData: ProcessedAnalyticsData = {
|
// Process time series data
|
||||||
data: processedData,
|
const dates = Object.keys(dateCounts).sort();
|
||||||
|
const startDate = new Date(dates[0]);
|
||||||
|
const endDate = new Date(dates[dates.length - 1]);
|
||||||
|
const today = new Date();
|
||||||
|
const actualEndDate = endDate > today ? today : endDate;
|
||||||
|
const daysDiff = Math.ceil(
|
||||||
|
(actualEndDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
const maxDays = 60;
|
||||||
|
|
||||||
|
let finalStartDate = startDate;
|
||||||
|
if (daysDiff > maxDays) {
|
||||||
|
finalStartDate = new Date(
|
||||||
|
actualEndDate.getTime() - maxDays * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSeries: Array<{
|
||||||
|
date: string;
|
||||||
|
displayDate: string;
|
||||||
|
count: number;
|
||||||
|
}> = [];
|
||||||
|
const currentDate = new Date(finalStartDate);
|
||||||
|
while (currentDate <= actualEndDate) {
|
||||||
|
const dateStr = currentDate.toISOString().split("T")[0];
|
||||||
|
timeSeries.push({
|
||||||
|
date: dateStr,
|
||||||
|
displayDate: currentDate.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
|
count: dateCounts[dateStr] || 0,
|
||||||
|
});
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const uniqueDates = new Set(Object.keys(dateCounts));
|
||||||
|
const daysCovered = uniqueDates.size;
|
||||||
|
const avgProjectsPerDay = daysCovered > 0 ? totalRecords / daysCovered : 0;
|
||||||
|
const authEnabledPercent =
|
||||||
|
totalRecords > 0
|
||||||
|
? Math.round((authEnabledCount / totalRecords) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Get most popular items
|
||||||
|
const getMostPopular = (counts: Record<string, number>) => {
|
||||||
|
return (
|
||||||
|
Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || "None"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyticsData: AggregatedAnalyticsData = {
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
totalRecords: processedData.length,
|
totalRecords,
|
||||||
|
timeSeries,
|
||||||
|
monthlyTimeSeries: Object.entries(monthlyCounts)
|
||||||
|
.map(([month, count]) => ({
|
||||||
|
month,
|
||||||
|
displayMonth: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.month.localeCompare(b.month)),
|
||||||
|
platformDistribution: Object.entries(platformCounts).map(
|
||||||
|
([name, value]) => ({ name, value }),
|
||||||
|
),
|
||||||
|
packageManagerDistribution: Object.entries(packageManagerCounts).map(
|
||||||
|
([name, value]) => ({ name, value }),
|
||||||
|
),
|
||||||
|
backendDistribution: Object.entries(backendCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
databaseDistribution: Object.entries(databaseCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
ormDistribution: Object.entries(ormCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
dbSetupDistribution: Object.entries(dbSetupCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
apiDistribution: Object.entries(apiCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
frontendDistribution: Object.entries(frontendCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
nodeVersionDistribution: Object.entries(nodeVersionCounts)
|
||||||
|
.map(([version, count]) => ({ version, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 5),
|
||||||
|
cliVersionDistribution: Object.entries(cliVersionCounts)
|
||||||
|
.map(([version, count]) => ({ version, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 8),
|
||||||
|
authDistribution: Object.entries(authCounts).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
gitDistribution: Object.entries(gitCounts).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
installDistribution: Object.entries(installCounts).map(
|
||||||
|
([name, value]) => ({ name, value }),
|
||||||
|
),
|
||||||
|
examplesDistribution: Object.entries(examplesCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
addonsDistribution: Object.entries(addonsCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
runtimeDistribution: Object.entries(runtimeCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value),
|
||||||
|
projectTypeDistribution: Object.entries(projectTypeCounts).map(
|
||||||
|
([name, value]) => ({ name, value }),
|
||||||
|
),
|
||||||
|
popularStackCombinations: Object.entries(stackComboCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 8),
|
||||||
|
databaseORMCombinations: Object.entries(dbORMComboCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 6),
|
||||||
|
hourlyDistribution: Array.from({ length: 24 }, (_, hour) => ({
|
||||||
|
hour: hour.toString().padStart(2, "0"),
|
||||||
|
displayHour: `${hour.toString().padStart(2, "0")}:00`,
|
||||||
|
count: hourlyCounts[hour] || 0,
|
||||||
|
})),
|
||||||
|
summary: {
|
||||||
|
totalProjects: totalRecords,
|
||||||
|
avgProjectsPerDay,
|
||||||
|
authEnabledPercent,
|
||||||
|
mostPopularFrontend: getMostPopular(frontendCounts),
|
||||||
|
mostPopularBackend: getMostPopular(backendCounts),
|
||||||
|
mostPopularORM: getMostPopular(ormCounts),
|
||||||
|
mostPopularAPI: getMostPopular(apiCounts),
|
||||||
|
mostPopularPackageManager: getMostPopular(packageManagerCounts),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("📤 Creating minimal analytics file...");
|
||||||
|
|
||||||
|
// Create minimal analytics data for public folder
|
||||||
|
const minimalAnalyticsData = {
|
||||||
|
totalProjects: totalRecords,
|
||||||
|
avgProjectsPerDay: avgProjectsPerDay.toFixed(1),
|
||||||
|
lastUpdated: lastUpdated,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write minimal file to public folder
|
||||||
|
const publicDir = join(process.cwd(), "public");
|
||||||
|
if (!existsSync(publicDir)) {
|
||||||
|
mkdirSync(publicDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const minimalFilePath = join(publicDir, "analytics-minimal.json");
|
||||||
|
writeFileSync(
|
||||||
|
minimalFilePath,
|
||||||
|
JSON.stringify(minimalAnalyticsData, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
console.log("📤 Uploading to Cloudflare R2...");
|
console.log("📤 Uploading to Cloudflare R2...");
|
||||||
|
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), "analytics-"));
|
const tempDir = mkdtempSync(join(tmpdir(), "analytics-"));
|
||||||
@@ -181,10 +482,16 @@ async function generateAnalyticsData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Generated analytics data with ${processedData.length} records`,
|
`✅ Generated optimized analytics data with ${totalRecords} records`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"📁 Created minimal analytics file: public/analytics-minimal.json",
|
||||||
);
|
);
|
||||||
console.log("📤 Uploaded to R2 bucket: bucket/analytics-data.json");
|
console.log("📤 Uploaded to R2 bucket: bucket/analytics-data.json");
|
||||||
console.log(`🕒 Last data update: ${lastUpdated}`);
|
console.log(`🕒 Last data update: ${lastUpdated}`);
|
||||||
|
console.log(
|
||||||
|
"📊 File size optimized: Pre-aggregated data instead of individual records",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Error generating analytics data:", error);
|
console.error("❌ Error generating analytics data:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1202,11 +1202,12 @@ const StackBuilder = () => {
|
|||||||
<TechIcon
|
<TechIcon
|
||||||
icon={tech.icon}
|
icon={tech.icon}
|
||||||
name={tech.name}
|
name={tech.name}
|
||||||
className={
|
className={cn(
|
||||||
tech.icon.startsWith("/icon/")
|
tech.icon.startsWith("/icon/")
|
||||||
? "h-3 w-3"
|
? "h-3 w-3"
|
||||||
: "h-3 w-3 text-xs"
|
: "h-3 w-3 text-xs",
|
||||||
}
|
tech.className,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tech.name}
|
{tech.name}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function Testimonials() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full min-w-0 overflow-hidden rounded border border-border">
|
<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="sticky top-0 z-10 border-border border-b px-2 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Play className="h-3 w-3 text-primary" />
|
<Play className="h-3 w-3 text-primary" />
|
||||||
<span className="font-semibold text-xs">[{video.id}]</span>
|
<span className="font-semibold text-xs">[{video.id}]</span>
|
||||||
@@ -211,7 +211,7 @@ export default function Testimonials() {
|
|||||||
<div className="w-full min-w-0 overflow-hidden">
|
<div className="w-full min-w-0 overflow-hidden">
|
||||||
<div style={{ width: "100%", minWidth: 0, maxWidth: "100%" }}>
|
<div style={{ width: "100%", minWidth: 0, maxWidth: "100%" }}>
|
||||||
<Suspense fallback={<TweetSkeleton />}>
|
<Suspense fallback={<TweetSkeleton />}>
|
||||||
<Tweet id={tweetId} />
|
<Tweet id={tweetId} components={components} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { api } from "@better-t-stack/backend/convex/_generated/api";
|
||||||
|
import { useNpmDownloadCounter } from "@erquhart/convex-oss-stats/react";
|
||||||
|
import NumberFlow, { continuous } from "@number-flow/react";
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
import {
|
import {
|
||||||
|
BarChart3,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
Github,
|
Github,
|
||||||
|
Package,
|
||||||
Star,
|
Star,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import discordLogo from "@/public/icon/discord.svg";
|
import analyticsData from "@/public/analytics-minimal.json";
|
||||||
import Footer from "./_components/footer";
|
import Footer from "./_components/footer";
|
||||||
import PackageIcon from "./_components/icons";
|
import PackageIcon from "./_components/icons";
|
||||||
import NpmPackage from "./_components/npm-package";
|
import NpmPackage from "./_components/npm-package";
|
||||||
@@ -19,8 +33,6 @@ import SponsorsSection from "./_components/sponsors-section";
|
|||||||
import Testimonials from "./_components/testimonials";
|
import Testimonials from "./_components/testimonials";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [stars, setStars] = useState<number | null>(null);
|
|
||||||
const [isLoadingStars, setIsLoadingStars] = useState(true);
|
|
||||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||||
const [selectedPM, setSelectedPM] = useState<"npm" | "pnpm" | "bun">("bun");
|
const [selectedPM, setSelectedPM] = useState<"npm" | "pnpm" | "bun">("bun");
|
||||||
|
|
||||||
@@ -30,36 +42,24 @@ export default function HomePage() {
|
|||||||
bun: "bun create better-t-stack@latest",
|
bun: "bun create better-t-stack@latest",
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchStars() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"https://api.github.com/repos/amanvarshney01/create-better-t-stack",
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setStars(data.stargazers_count);
|
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch GitHub stars");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching GitHub stars:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingStars(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchStars();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const copyCommand = (command: string, packageManager: string) => {
|
const copyCommand = (command: string, packageManager: string) => {
|
||||||
navigator.clipboard.writeText(command);
|
navigator.clipboard.writeText(command);
|
||||||
setCopiedCommand(packageManager);
|
setCopiedCommand(packageManager);
|
||||||
setTimeout(() => setCopiedCommand(null), 2000);
|
setTimeout(() => setCopiedCommand(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const githubRepo = useQuery(api.stats.getGithubRepo, {
|
||||||
|
name: "AmanVarshney01/create-better-t-stack",
|
||||||
|
});
|
||||||
|
const npmPackages = useQuery(api.stats.getNpmPackages, {
|
||||||
|
names: ["create-better-t-stack"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveNpmDownloadCount = useNpmDownloadCounter(npmPackages);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto min-h-svh max-w-[1280px]">
|
<div className="mx-auto min-h-svh max-w-[1280px]">
|
||||||
<main className="mx-auto px-4 pt-16">
|
<main className="mx-auto px-4 pt-12">
|
||||||
<div className="mb-8 flex items-center justify-center">
|
<div className="mb-8 flex items-center justify-center">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-4 md:gap-6">
|
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-4 md:gap-6">
|
||||||
<pre className="ascii-art text-primary text-xs leading-tight sm:text-sm">
|
<pre className="ascii-art text-primary text-xs leading-tight sm:text-sm">
|
||||||
@@ -114,115 +114,281 @@ export default function HomePage() {
|
|||||||
<NpmPackage />
|
<NpmPackage />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className=" mb-8 rounded border border-border p-4">
|
<div className="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="flex h-full flex-col justify-between rounded border border-border p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<Terminal className="h-4 w-4 text-primary" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm">QUICK_START</span>
|
<Terminal className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-semibold text-sm">CLI_COMMAND</span>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 rounded border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted/10"
|
||||||
|
>
|
||||||
|
<PackageIcon pm={selectedPM} className="h-3 w-3" />
|
||||||
|
<span>{selectedPM.toUpperCase()}</span>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{(["bun", "pnpm", "npm"] as const).map((pm) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={pm}
|
||||||
|
onClick={() => setSelectedPM(pm)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
selectedPM === pm && "bg-accent text-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PackageIcon pm={pm} className="h-3 w-3" />
|
||||||
|
<span>{pm.toUpperCase()}</span>
|
||||||
|
{selectedPM === pm && (
|
||||||
|
<Check className="ml-auto h-3 w-3 text-background" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded border border-border p-0.5">
|
|
||||||
{(["bun", "pnpm", "npm"] as const).map((pm) => (
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between rounded border border-border p-3">
|
||||||
|
<div className="flex items-center gap-2 font-mono text-sm">
|
||||||
|
<span className="text-primary">$</span>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{commands[selectedPM]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={pm}
|
onClick={() => copyCommand(commands[selectedPM], selectedPM)}
|
||||||
onClick={() => setSelectedPM(pm)}
|
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-muted/10"
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors duration-150",
|
|
||||||
selectedPM === pm
|
|
||||||
? "bg-primary/20 text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<PackageIcon pm={pm} className="h-3 w-3" />
|
{copiedCommand === selectedPM ? (
|
||||||
{pm.toUpperCase()}
|
<Check className="h-3 w-3 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{copiedCommand === selectedPM ? "COPIED!" : "COPY"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<Link href="/new">
|
||||||
<div className="flex items-center justify-between rounded border border-border p-3">
|
<div className="group flex h-full cursor-pointer flex-col justify-between rounded border border-border p-4 transition-colors hover:bg-muted/10">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<span className="text-primary">$</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className=" text-foreground">{commands[selectedPM]}</span>
|
<ChevronRight className="h-4 w-4 text-primary transition-transform group-hover:translate-x-1" />
|
||||||
|
<span className="font-semibold text-sm">STACK_BUILDER</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-border bg-muted/30 px-2 py-1 text-xs">
|
||||||
|
INTERACTIVE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between rounded border border-border p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-primary">⚡</span>
|
||||||
|
<span className="text-foreground">
|
||||||
|
Interactive configuration wizard
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-border bg-muted/30 px-2 py-1 text-xs">
|
||||||
|
START
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyCommand(commands[selectedPM], selectedPM)}
|
|
||||||
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
{copiedCommand === selectedPM ? (
|
|
||||||
<Check className="h-3 w-3 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{copiedCommand === selectedPM ? "COPIED!" : "COPY"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:mb-12 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="mb-8 grid grid-cols-1 gap-4 sm:mb-12 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link href="/new">
|
<Link href="/analytics">
|
||||||
<div className="group cursor-pointer rounded border border-border p-4 transition-colors focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2 hover:bg-muted/50">
|
<div className="cursor-pointer rounded border border-border p-4 transition-colors hover:bg-muted/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<ChevronRight className="h-4 w-4 text-primary transition-transform group-hover:translate-x-1" />
|
<Terminal className="h-4 w-4 text-primary" />
|
||||||
<span className="font-semibold text-sm sm:text-base">
|
<span className="font-semibold text-sm sm:text-base">
|
||||||
STACK_BUILDER.SH
|
CLI_ANALYTICS.JSON
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground text-xs sm:text-sm">
|
|
||||||
[EXEC] Interactive configuration wizard
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
<div className="space-y-3">
|
||||||
href="https://github.com/amanvarshney01/create-better-t-stack"
|
<div className="flex items-center justify-between">
|
||||||
target="_blank"
|
<span className="flex items-center gap-1 font-mono text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
rel="noopener noreferrer"
|
<BarChart3 className="h-3 w-3" />
|
||||||
>
|
Total Projects
|
||||||
<div className="group cursor-pointer rounded border border-border p-4 transition-colors focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2 hover:bg-muted/50">
|
</span>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<NumberFlow
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
value={analyticsData.totalProjects}
|
||||||
<Github className="h-4 w-4 flex-shrink-0 text-primary" />
|
className="font-bold font-mono text-lg text-primary tabular-nums"
|
||||||
<span className="truncate font-semibold text-sm sm:text-base">
|
transformTiming={{
|
||||||
GITHUB_REPO.GIT
|
duration: 1000,
|
||||||
|
easing: "ease-out",
|
||||||
|
}}
|
||||||
|
trend={1}
|
||||||
|
willChange
|
||||||
|
isolate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 font-mono text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
Avg/Day
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-foreground text-sm">
|
||||||
|
{analyticsData.avgProjectsPerDay}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{stars !== null && !isLoadingStars && (
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border border-border bg-muted/30 px-2 py-1 text-xs">
|
<div className="border-border/50 border-t pt-3">
|
||||||
<Star className="h-3 w-3 text-accent" />
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="tabular-nums">{stars}</span>
|
<span className="font-mono text-muted-foreground">
|
||||||
|
Last Updated
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-accent">
|
||||||
|
{analyticsData.lastUpdated ||
|
||||||
|
new Date().toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground text-xs sm:text-sm">
|
|
||||||
[LINK] Star the repository on GitHub
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="https://discord.gg/ZYsbjpDaM5"
|
href="https://github.com/AmanVarshney01/create-better-t-stack"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="sm:col-span-2 lg:col-span-1"
|
|
||||||
>
|
>
|
||||||
<div className="group cursor-pointer rounded border border-border p-4 transition-colors focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2 hover:bg-muted/50">
|
<div className="cursor-pointer rounded border border-border p-4 transition-colors hover:bg-muted/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Image
|
<Github className="h-4 w-4 text-primary" />
|
||||||
src={discordLogo}
|
|
||||||
alt="discord"
|
|
||||||
className="h-4 w-4 flex-shrink-0 invert-0 dark:invert"
|
|
||||||
/>
|
|
||||||
<span className="font-semibold text-sm sm:text-base">
|
<span className="font-semibold text-sm sm:text-base">
|
||||||
DISCORD_CHAT.IRC
|
GITHUB_REPO.GIT
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground text-xs sm:text-sm">
|
|
||||||
[JOIN] Connect to developer community
|
<div className="space-y-3">
|
||||||
</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 font-mono text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
Stars
|
||||||
|
</span>
|
||||||
|
{githubRepo?.starCount !== undefined ? (
|
||||||
|
<NumberFlow
|
||||||
|
value={githubRepo.starCount}
|
||||||
|
className="font-bold font-mono text-lg text-primary tabular-nums"
|
||||||
|
transformTiming={{
|
||||||
|
duration: 800,
|
||||||
|
easing: "ease-out",
|
||||||
|
}}
|
||||||
|
trend={1}
|
||||||
|
willChange
|
||||||
|
isolate
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-6 w-16 animate-pulse rounded bg-muted/50 font-bold font-mono text-lg text-primary tabular-nums" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 font-mono text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Contributors
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-foreground text-sm">
|
||||||
|
{githubRepo?.contributorCount || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-border/50 border-t pt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground">
|
||||||
|
Repository
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-accent">
|
||||||
|
AmanVarshney01/create-better-t-stack
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="https://www.npmjs.com/package/create-better-t-stack"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<div className="cursor-pointer rounded border border-border p-4 transition-colors hover:bg-muted/10">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Terminal className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-semibold text-sm sm:text-base">
|
||||||
|
NPM_PACKAGE.JS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 font-mono text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<Package className="h-3 w-3" />
|
||||||
|
Downloads
|
||||||
|
</span>
|
||||||
|
{liveNpmDownloadCount?.count !== undefined ? (
|
||||||
|
<NumberFlow
|
||||||
|
value={liveNpmDownloadCount.count}
|
||||||
|
className="font-bold font-mono text-lg text-primary tabular-nums"
|
||||||
|
transformTiming={{
|
||||||
|
duration: liveNpmDownloadCount.intervalMs || 1000,
|
||||||
|
easing: "linear",
|
||||||
|
}}
|
||||||
|
trend={1}
|
||||||
|
willChange
|
||||||
|
plugins={[continuous]}
|
||||||
|
isolate
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-6 w-20 animate-pulse rounded bg-muted/50 font-bold font-mono text-lg text-primary tabular-nums" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 font-mono text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
Avg/Day
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-foreground text-sm">
|
||||||
|
{npmPackages?.dayOfWeekAverages
|
||||||
|
? Math.round(
|
||||||
|
npmPackages.dayOfWeekAverages.reduce(
|
||||||
|
(a, b) => a + b,
|
||||||
|
0,
|
||||||
|
) / 7,
|
||||||
|
)
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-border/50 border-t pt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground">
|
||||||
|
Package
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-accent">
|
||||||
|
create-better-t-stack
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
25
apps/web/src/app/docs-og/[...slug]/route.tsx
Normal file
25
apps/web/src/app/docs-og/[...slug]/route.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { generateOGImage } from "fumadocs-ui/og";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { source } from "@/lib/source";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string[] }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const page = source.getPage(slug.slice(0, -1));
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return generateOGImage({
|
||||||
|
title: page.data.title,
|
||||||
|
description: page.data.description,
|
||||||
|
site: "Better T Stack",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return source.generateParams().map((page) => ({
|
||||||
|
...page,
|
||||||
|
slug: [...page.slug, "image.png"],
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DocsTitle,
|
DocsTitle,
|
||||||
} from "fumadocs-ui/page";
|
} from "fumadocs-ui/page";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { LLMCopyButton, ViewOptions } from "@/components/ai/page-actions";
|
||||||
import { source } from "@/lib/source";
|
import { source } from "@/lib/source";
|
||||||
|
|
||||||
export default async function Page(props: {
|
export default async function Page(props: {
|
||||||
@@ -20,6 +21,13 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||||
|
<div className="flex flex-row items-center gap-2 border-b pt-2 pb-6">
|
||||||
|
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
|
||||||
|
<ViewOptions
|
||||||
|
markdownUrl={`${page.url}.mdx`}
|
||||||
|
githubUrl={`https://github.com/amanvarshney01/create-better-t-stack/blob/dev/apps/docs/content/docs/${page.path}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<DocsTitle>{page.data.title}</DocsTitle>
|
<DocsTitle>{page.data.title}</DocsTitle>
|
||||||
<DocsDescription>{page.data.description}</DocsDescription>
|
<DocsDescription>{page.data.description}</DocsDescription>
|
||||||
<DocsBody>
|
<DocsBody>
|
||||||
@@ -33,15 +41,24 @@ export async function generateStaticParams() {
|
|||||||
return source.generateParams();
|
return source.generateParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(props: {
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
params: Promise<{ slug?: string[] }>;
|
params: Promise<{ slug?: string[] }>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const { slug = [] } = await params;
|
||||||
const page = source.getPage(params.slug);
|
const page = source.getPage(slug);
|
||||||
if (!page) notFound();
|
if (!page) notFound();
|
||||||
|
const image = ["/docs-og", ...slug, "image.png"].join("/");
|
||||||
return {
|
return {
|
||||||
title: page.data.title,
|
title: page.data.title,
|
||||||
description: page.data.description,
|
description: page.data.description,
|
||||||
|
openGraph: {
|
||||||
|
images: image,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
images: image,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
--font-sans: var(--font-geist);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -133,9 +135,6 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--font-sans: Montserrat, sans-serif;
|
|
||||||
--font-mono: Fira Code, monospace;
|
|
||||||
--font-serif: Georgia, serif;
|
|
||||||
--radius: 0.35rem;
|
--radius: 0.35rem;
|
||||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||||
@@ -198,9 +197,6 @@
|
|||||||
--sidebar-ring: #8839ef;
|
--sidebar-ring: #8839ef;
|
||||||
--destructive-foreground: oklch(1 0 0);
|
--destructive-foreground: oklch(1 0 0);
|
||||||
--radius: 0.35rem;
|
--radius: 0.35rem;
|
||||||
--font-sans: Montserrat, sans-serif;
|
|
||||||
--font-serif: Georgia, serif;
|
|
||||||
--font-mono: Fira Code, monospace;
|
|
||||||
--shadow-color: hsl(240 30% 25%);
|
--shadow-color: hsl(240 30% 25%);
|
||||||
--shadow-opacity: 0.12;
|
--shadow-opacity: 0.12;
|
||||||
--shadow-blur: 6px;
|
--shadow-blur: 6px;
|
||||||
@@ -266,9 +262,6 @@
|
|||||||
--sidebar-ring: #cba6f7;
|
--sidebar-ring: #cba6f7;
|
||||||
--destructive-foreground: #11111b;
|
--destructive-foreground: #11111b;
|
||||||
--radius: 0.35rem;
|
--radius: 0.35rem;
|
||||||
--font-sans: Montserrat, sans-serif;
|
|
||||||
--font-serif: Georgia, serif;
|
|
||||||
--font-mono: Fira Code, monospace;
|
|
||||||
--shadow-color: hsl(240 30% 5%);
|
--shadow-color: hsl(240 30% 5%);
|
||||||
--shadow-opacity: 0.25;
|
--shadow-opacity: 0.25;
|
||||||
--shadow-blur: 8px;
|
--shadow-blur: 8px;
|
||||||
@@ -351,12 +344,6 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .terminal-matrix-bg {
|
|
||||||
background: linear-gradient(90deg, transparent 98%, var(--border) 100%),
|
|
||||||
linear-gradient(0deg, transparent 98%, var(--border) 100%);
|
|
||||||
background-size: 20px 20px;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.ascii-art {
|
.ascii-art {
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -407,3 +394,139 @@
|
|||||||
.file-load-animation {
|
.file-load-animation {
|
||||||
animation: file-load 0.3s ease-out forwards;
|
animation: file-load 0.3s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chart styling to match terminal aesthetic */
|
||||||
|
.recharts-wrapper {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-cartesian-axis-tick-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-cartesian-axis-line {
|
||||||
|
stroke: hsl(var(--border));
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-cartesian-grid-horizontal line,
|
||||||
|
.recharts-cartesian-grid-vertical line {
|
||||||
|
stroke: hsl(var(--border));
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-tooltip-wrapper {
|
||||||
|
background: hsl(var(--popover));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-tooltip-item {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-legend-item-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-pie-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-bar-rectangle {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-bar-rectangle:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-pie-sector {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-pie-sector:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-area-curve {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-area-curve:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart container styling */
|
||||||
|
.chart-container {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal-style chart headers */
|
||||||
|
.chart-header {
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart hover effects */
|
||||||
|
.chart-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:hover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart loading state */
|
||||||
|
.chart-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart error state */
|
||||||
|
.chart-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const baseOptions: BaseLayoutProps = {
|
|||||||
title: (
|
title: (
|
||||||
<>
|
<>
|
||||||
{logo}
|
{logo}
|
||||||
<span className="font-medium [.uwu_&]:hidden [header_&]:text-[15px]">
|
<span className="font-medium font-mono text-md tracking-tighter ">
|
||||||
Better T Stack
|
Better T Stack
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,19 +2,27 @@ export const dynamic = "force-static";
|
|||||||
|
|
||||||
import { RootProvider } from "fumadocs-ui/provider";
|
import { RootProvider } from "fumadocs-ui/provider";
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Poppins } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import SearchDialog from "@/components/search";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
|
import Providers from "@/components/providers";
|
||||||
|
|
||||||
const poppins = Poppins({
|
const geist = Geist({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "600", "700", "800"],
|
weight: ["400", "500", "600", "700"],
|
||||||
|
variable: "--font-geist",
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
variable: "--font-geist-mono",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ogImage =
|
const ogImage =
|
||||||
"https://api.screenshothis.com/v1/screenshots/take?api_key=ss_live_NQJgRXqHcKPwnoMTuQmgiwLIGbVfihjpMyQhgsaMyNBHTyesvrxpYNXmdgcnxipc&url=https%3A%2F%2Fbetter-t-stack.dev%2F&width=1200&height=630&block_ads=true&block_cookie_banners=true&block_trackers=true&device_scale_factor=0.75&prefers_color_scheme=dark&is_cached=true";
|
"https://api.screenshothis.com/v1/screenshots/take?api_key=ss_live_NQJgRXqHcKPwnoMTuQmgiwLIGbVfihjpMyQhgsaMyNBHTyesvrxpYNXmdgcnxipc&url=https%3A%2F%2Fbetter-t-stack.dev%2F&width=1200&height=630&block_ads=true&block_cookie_banners=true&block_trackers=true&device_scale_factor=0.70&prefers_color_scheme=dark&is_cached=true&cache_key=bts";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Better-T Stack",
|
title: "Better-T Stack",
|
||||||
@@ -99,10 +107,15 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={cn(geist.variable, geistMono.variable, "font-sans")}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<body>
|
<body>
|
||||||
<RootProvider
|
<RootProvider
|
||||||
search={{
|
search={{
|
||||||
|
SearchDialog,
|
||||||
options: {
|
options: {
|
||||||
type: "static",
|
type: "static",
|
||||||
},
|
},
|
||||||
@@ -112,8 +125,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||||||
defaultTheme: "system",
|
defaultTheme: "system",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NuqsAdapter>{children}</NuqsAdapter>
|
<Providers>{children}</Providers>
|
||||||
<Toaster />
|
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
12
apps/web/src/app/llms-full.txt/route.ts
Normal file
12
apps/web/src/app/llms-full.txt/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { getLLMText } from "@/lib/get-llm-text";
|
||||||
|
import { source } from "@/lib/source";
|
||||||
|
|
||||||
|
// cached forever
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const scan = source.getPages().map(getLLMText);
|
||||||
|
const scanned = await Promise.all(scan);
|
||||||
|
|
||||||
|
return new Response(scanned.join("\n\n"));
|
||||||
|
}
|
||||||
21
apps/web/src/app/llms.mdx/[[...slug]]/route.ts
Normal file
21
apps/web/src/app/llms.mdx/[[...slug]]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getLLMText } from "@/lib/get-llm-text";
|
||||||
|
import { source } from "@/lib/source";
|
||||||
|
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug?: string[] }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const page = source.getPage(slug);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return new NextResponse(await getLLMText(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return source.generateParams();
|
||||||
|
}
|
||||||
184
apps/web/src/components/ai/page-actions.tsx
Normal file
184
apps/web/src/components/ai/page-actions.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { buttonVariants } from "fumadocs-ui/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "fumadocs-ui/components/ui/popover";
|
||||||
|
import { useCopyButton } from "fumadocs-ui/utils/use-copy-button";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
MessageCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { cn } from "../../../lib/cn";
|
||||||
|
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function LLMCopyButton({
|
||||||
|
/**
|
||||||
|
* A URL to fetch the raw Markdown/MDX content of page
|
||||||
|
*/
|
||||||
|
markdownUrl,
|
||||||
|
}: {
|
||||||
|
markdownUrl: string;
|
||||||
|
}) {
|
||||||
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
const [checked, onClick] = useCopyButton(async () => {
|
||||||
|
const cached = cache.get(markdownUrl);
|
||||||
|
if (cached) return navigator.clipboard.writeText(cached);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
"text/plain": fetch(markdownUrl).then(async (res) => {
|
||||||
|
const content = await res.text();
|
||||||
|
cache.set(markdownUrl, content);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
color: "secondary",
|
||||||
|
size: "sm",
|
||||||
|
className: "gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground",
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{checked ? <Check /> : <Copy />}
|
||||||
|
Copy Markdown
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionVariants = cva(
|
||||||
|
"inline-flex items-center gap-2 rounded-lg p-2 text-sm hover:bg-fd-accent hover:text-fd-accent-foreground [&_svg]:size-4",
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ViewOptions({
|
||||||
|
markdownUrl,
|
||||||
|
githubUrl,
|
||||||
|
}: {
|
||||||
|
/**
|
||||||
|
* A URL to the raw Markdown/MDX content of page
|
||||||
|
*/
|
||||||
|
markdownUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source file URL on GitHub
|
||||||
|
*/
|
||||||
|
githubUrl: string;
|
||||||
|
}) {
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const fullMarkdownUrl =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? new URL(markdownUrl, window.location.origin)
|
||||||
|
: "loading";
|
||||||
|
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Open in GitHub",
|
||||||
|
href: githubUrl,
|
||||||
|
icon: (
|
||||||
|
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||||
|
<title>GitHub</title>
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Open in ChatGPT",
|
||||||
|
href: `https://chatgpt.com/?${new URLSearchParams({
|
||||||
|
hints: "search",
|
||||||
|
q,
|
||||||
|
})}`,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>OpenAI</title>
|
||||||
|
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Open in Claude",
|
||||||
|
href: `https://claude.ai/new?${new URLSearchParams({
|
||||||
|
q,
|
||||||
|
})}`,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Anthropic</title>
|
||||||
|
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Open in T3 Chat",
|
||||||
|
href: `https://t3.chat/new?${new URLSearchParams({
|
||||||
|
q,
|
||||||
|
})}`,
|
||||||
|
icon: <MessageCircleIcon />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [githubUrl, markdownUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
color: "secondary",
|
||||||
|
size: "sm",
|
||||||
|
className: "gap-2",
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="flex flex-col overflow-auto">
|
||||||
|
{items.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
className={cn(optionVariants())}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.title}
|
||||||
|
<ExternalLinkIcon className="ms-auto size-3.5 text-fd-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/src/components/providers.tsx
Normal file
18
apps/web/src/components/providers.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
|
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL || "");
|
||||||
|
|
||||||
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConvexProvider client={convex}>
|
||||||
|
<NuqsAdapter>{children}</NuqsAdapter>
|
||||||
|
</ConvexProvider>
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/web/src/components/search.tsx
Normal file
69
apps/web/src/components/search.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
import { create } from "@orama/orama";
|
||||||
|
import { useDocsSearch } from "fumadocs-core/search/client";
|
||||||
|
import {
|
||||||
|
SearchDialog,
|
||||||
|
SearchDialogClose,
|
||||||
|
SearchDialogContent,
|
||||||
|
SearchDialogHeader,
|
||||||
|
SearchDialogIcon,
|
||||||
|
SearchDialogInput,
|
||||||
|
SearchDialogList,
|
||||||
|
SearchDialogOverlay,
|
||||||
|
type SharedProps,
|
||||||
|
} from "fumadocs-ui/components/dialog/search";
|
||||||
|
import { customSearchItems, filterCustomItems } from "@/lib/search-config";
|
||||||
|
|
||||||
|
function initOrama() {
|
||||||
|
return create({
|
||||||
|
schema: {
|
||||||
|
_: "string",
|
||||||
|
},
|
||||||
|
language: "english",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DefaultSearchDialog(props: SharedProps) {
|
||||||
|
const { search, setSearch, query } = useDocsSearch({
|
||||||
|
type: "static",
|
||||||
|
initOrama,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCustomItems = filterCustomItems(
|
||||||
|
customSearchItems,
|
||||||
|
search || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const combinedResults =
|
||||||
|
query.data === "empty" || !query.data
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
...query.data,
|
||||||
|
...filteredCustomItems.map((item, index) => ({
|
||||||
|
id: `custom-${index}`,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
content: item.content,
|
||||||
|
type: "page" as const,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchDialog
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
isLoading={query.isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SearchDialogOverlay />
|
||||||
|
<SearchDialogContent>
|
||||||
|
<SearchDialogHeader>
|
||||||
|
<SearchDialogIcon />
|
||||||
|
<SearchDialogInput />
|
||||||
|
<SearchDialogClose />
|
||||||
|
</SearchDialogHeader>
|
||||||
|
<SearchDialogList items={combinedResults} />
|
||||||
|
</SearchDialogContent>
|
||||||
|
</SearchDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,7 +53,13 @@ export function SpecialSponsorBanner() {
|
|||||||
<div className="">
|
<div className="">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{specialSponsors.map((sponsor) => (
|
{specialSponsors.map((sponsor) => (
|
||||||
<div key={sponsor.sponsor.login} className="flex items-center gap-3">
|
<a
|
||||||
|
key={sponsor.sponsor.login}
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
href={sponsor.sponsor.websiteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={sponsor.sponsor.customLogoUrl || sponsor.sponsor.avatarUrl}
|
src={sponsor.sponsor.customLogoUrl || sponsor.sponsor.avatarUrl}
|
||||||
alt={sponsor.sponsor.name || sponsor.sponsor.login}
|
alt={sponsor.sponsor.name || sponsor.sponsor.login}
|
||||||
@@ -67,7 +73,7 @@ export function SpecialSponsorBanner() {
|
|||||||
{sponsor.sponsor.name || sponsor.sponsor.login}
|
{sponsor.sponsor.name || sponsor.sponsor.login}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
257
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
257
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 font-medium text-sm data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-muted-foreground text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
26
apps/web/src/lib/get-llm-text.ts
Normal file
26
apps/web/src/lib/get-llm-text.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { InferPageType } from "fumadocs-core/source";
|
||||||
|
import { remarkInclude } from "fumadocs-mdx/config";
|
||||||
|
import { remark } from "remark";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkMdx from "remark-mdx";
|
||||||
|
import type { source } from "@/lib/source";
|
||||||
|
|
||||||
|
const processor = remark()
|
||||||
|
.use(remarkMdx)
|
||||||
|
// needed for Fumadocs MDX
|
||||||
|
.use(remarkInclude)
|
||||||
|
.use(remarkGfm);
|
||||||
|
|
||||||
|
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||||
|
const processed = await processor.process({
|
||||||
|
path: page.data._file.absolutePath,
|
||||||
|
value: page.data.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `# ${page.data.title}
|
||||||
|
URL: ${page.url}
|
||||||
|
|
||||||
|
${page.data.description}
|
||||||
|
|
||||||
|
${processed.value}`;
|
||||||
|
}
|
||||||
66
apps/web/src/lib/search-config.ts
Normal file
66
apps/web/src/lib/search-config.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export interface CustomSearchItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customSearchItems: CustomSearchItem[] = [
|
||||||
|
{
|
||||||
|
title: "Analytics",
|
||||||
|
url: "/analytics",
|
||||||
|
content: "Analytics",
|
||||||
|
tags: ["analytics", "insights", "statistics", "data", "metrics"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Showcase",
|
||||||
|
url: "/showcase",
|
||||||
|
content: "Showcase",
|
||||||
|
tags: ["showcase", "projects", "examples", "demos", "portfolio"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Builder",
|
||||||
|
url: "/new",
|
||||||
|
content: "Builder",
|
||||||
|
tags: ["builder", "create", "new", "project", "setup"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "GitHub Repository",
|
||||||
|
url: "https://github.com/AmanVarshney01/create-better-t-stack",
|
||||||
|
content: "GitHub",
|
||||||
|
tags: ["github", "source", "code", "repository", "contribute", "star"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "NPM Package",
|
||||||
|
url: "https://www.npmjs.com/package/create-better-t-stack",
|
||||||
|
content: "NPM",
|
||||||
|
tags: ["npm", "package", "install", "cli", "tool"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "X (Twitter)",
|
||||||
|
url: "https://x.com/amanvarshney01",
|
||||||
|
content: "X",
|
||||||
|
tags: ["twitter", "x", "social", "updates", "announcements", "follow"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Discord Community",
|
||||||
|
url: "https://discord.gg/ZYsbjpDaM5",
|
||||||
|
content: "Discord",
|
||||||
|
tags: ["discord", "community", "chat", "help", "support", "discussions"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function filterCustomItems(
|
||||||
|
items: CustomSearchItem[],
|
||||||
|
searchQuery: string,
|
||||||
|
): CustomSearchItem[] {
|
||||||
|
if (!searchQuery) return items;
|
||||||
|
|
||||||
|
const searchLower = searchQuery.toLowerCase();
|
||||||
|
return items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.title.toLowerCase().includes(searchLower) ||
|
||||||
|
item.content.toLowerCase().includes(searchLower) ||
|
||||||
|
item.tags.some((tag) => tag.toLowerCase().includes(searchLower)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../../node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "better-t-stack",
|
|
||||||
"compatibility_date": "2025-07-13",
|
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
|
||||||
"keep_names": false,
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
"!**/.turbo",
|
"!**/.turbo",
|
||||||
"!**/package.json",
|
"!**/package.json",
|
||||||
"!**/analytics-data.json",
|
"!**/analytics-data.json",
|
||||||
"!**/schema.json"
|
"!**/schema.json",
|
||||||
|
"!**/_generated/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"check": "turbo check",
|
"check": "turbo check",
|
||||||
"format": "biome check --write .",
|
"format": "biome check --write .",
|
||||||
"publish-packages": "turbo run build --filter=create-better-t-stack && changeset publish",
|
"publish-packages": "turbo run build --filter=create-better-t-stack && changeset publish",
|
||||||
"deploy:web": "bun run --filter=web generate-analytics && bun run --filter=web generate-schema && turbo run --filter=web deploy"
|
"deploy:web": "bun run --filter=web generate-analytics && bun run --filter=web generate-schema && vercel --prod"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.1.2",
|
"@biomejs/biome": "2.1.2",
|
||||||
|
|||||||
3
packages/backend/.gitignore
vendored
Normal file
3
packages/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
.env.local
|
||||||
|
_generated
|
||||||
90
packages/backend/convex/README.md
Normal file
90
packages/backend/convex/README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Welcome to your Convex functions directory!
|
||||||
|
|
||||||
|
Write your Convex functions here.
|
||||||
|
See https://docs.convex.dev/functions for more.
|
||||||
|
|
||||||
|
A query function that takes two arguments looks like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// functions.js
|
||||||
|
import { query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
export const myQueryFunction = query({
|
||||||
|
// Validators for arguments.
|
||||||
|
args: {
|
||||||
|
first: v.number(),
|
||||||
|
second: v.string(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Function implementation.
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Read the database as many times as you need here.
|
||||||
|
// See https://docs.convex.dev/database/reading-data.
|
||||||
|
const documents = await ctx.db.query("tablename").collect();
|
||||||
|
|
||||||
|
// Arguments passed from the client are properties of the args object.
|
||||||
|
console.log(args.first, args.second);
|
||||||
|
|
||||||
|
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
|
||||||
|
// remove non-public properties, or create new objects.
|
||||||
|
return documents;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Using this query function in a React component looks like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const data = useQuery(api.functions.myQueryFunction, {
|
||||||
|
first: 10,
|
||||||
|
second: "hello",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
A mutation function looks like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// functions.js
|
||||||
|
import { mutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
export const myMutationFunction = mutation({
|
||||||
|
// Validators for arguments.
|
||||||
|
args: {
|
||||||
|
first: v.string(),
|
||||||
|
second: v.string(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Function implementation.
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Insert or modify documents in the database here.
|
||||||
|
// Mutations can also read from the database like queries.
|
||||||
|
// See https://docs.convex.dev/database/writing-data.
|
||||||
|
const message = { body: args.first, author: args.second };
|
||||||
|
const id = await ctx.db.insert("messages", message);
|
||||||
|
|
||||||
|
// Optionally, return a value from your mutation.
|
||||||
|
return await ctx.db.get(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Using this mutation function in a React component looks like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const mutation = useMutation(api.functions.myMutationFunction);
|
||||||
|
function handleButtonPress() {
|
||||||
|
// fire and forget, the most common way to use mutations
|
||||||
|
mutation({ first: "Hello!", second: "me" });
|
||||||
|
// OR
|
||||||
|
// use the result once the mutation has completed
|
||||||
|
mutation({ first: "Hello!", second: "me" }).then((result) =>
|
||||||
|
console.log(result),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the Convex CLI to push your functions to a deployment. See everything
|
||||||
|
the Convex CLI can do by running `npx convex -h` in your project root
|
||||||
|
directory. To learn more, launch the docs with `npx convex docs`.
|
||||||
7
packages/backend/convex/convex.config.ts
Normal file
7
packages/backend/convex/convex.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ossStats from "@erquhart/convex-oss-stats/convex.config";
|
||||||
|
import { defineApp } from "convex/server";
|
||||||
|
|
||||||
|
const app = defineApp();
|
||||||
|
app.use(ossStats);
|
||||||
|
|
||||||
|
export default app;
|
||||||
7
packages/backend/convex/healthCheck.ts
Normal file
7
packages/backend/convex/healthCheck.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { query } from "./_generated/server";
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
handler: async () => {
|
||||||
|
return "OK";
|
||||||
|
},
|
||||||
|
});
|
||||||
7
packages/backend/convex/http.ts
Normal file
7
packages/backend/convex/http.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { httpRouter } from "convex/server";
|
||||||
|
import { ossStats } from "./stats";
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
ossStats.registerRoutes(http);
|
||||||
|
export default http;
|
||||||
19
packages/backend/convex/stats.ts
Normal file
19
packages/backend/convex/stats.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { OssStats } from "@erquhart/convex-oss-stats";
|
||||||
|
import { components } from "./_generated/api";
|
||||||
|
|
||||||
|
export const ossStats = new OssStats(components.ossStats, {
|
||||||
|
githubOwners: ["AmanVarshney01"],
|
||||||
|
githubRepos: ["AmanVarshney01/create-better-t-stack"],
|
||||||
|
npmPackages: ["create-better-t-stack"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
sync,
|
||||||
|
clearAndSync,
|
||||||
|
getGithubOwner,
|
||||||
|
getNpmOrg,
|
||||||
|
getGithubRepo,
|
||||||
|
getGithubRepos,
|
||||||
|
getNpmPackage,
|
||||||
|
getNpmPackages,
|
||||||
|
} = ossStats.api();
|
||||||
25
packages/backend/convex/tsconfig.json
Normal file
25
packages/backend/convex/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
/* This TypeScript project config describes the environment that
|
||||||
|
* Convex functions run in and is used to typecheck them.
|
||||||
|
* You can modify it, but some settings required to use Convex.
|
||||||
|
*/
|
||||||
|
"compilerOptions": {
|
||||||
|
/* These settings are not required by Convex and can be modified. */
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|
||||||
|
/* These compiler options are required by Convex */
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": ["ES2021", "dom"],
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["./**/*"],
|
||||||
|
"exclude": ["./_generated"]
|
||||||
|
}
|
||||||
18
packages/backend/package.json
Normal file
18
packages/backend/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@better-t-stack/backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "convex dev",
|
||||||
|
"dev:setup": "convex dev --configure --until-success"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@erquhart/convex-oss-stats": "^0.8.1",
|
||||||
|
"convex": "^1.25.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user