OhMyDitzzy
commited on
Commit
·
6d9f36a
1
Parent(s):
0247e61
Feat: add project
Browse files- .gitignore +5 -0
- Dockerfile +22 -0
- _build/script.ts +29 -0
- components.json +22 -0
- package.json +53 -0
- postcss.config.js +6 -0
- src/client/App.tsx +33 -0
- src/client/hooks/usePlugin.ts +203 -0
- src/client/index.css +90 -0
- src/client/main.tsx +13 -0
- src/client/pages/Docs.tsx +256 -0
- src/client/pages/Home.tsx +267 -0
- src/client/pages/not-found.tsx +21 -0
- src/components/CodeBlock.tsx +63 -0
- src/components/CodeSnippet.tsx +129 -0
- src/components/ErrorBoundary.tsx +184 -0
- src/components/Footer.tsx +45 -0
- src/components/Navbar.tsx +118 -0
- src/components/PluginCard.tsx +506 -0
- src/components/StatsCard.tsx +30 -0
- src/components/VisitorChart.tsx +104 -0
- src/components/ui/badge.tsx +36 -0
- src/components/ui/button.tsx +57 -0
- src/components/ui/card.tsx +76 -0
- src/components/ui/input.tsx +22 -0
- src/components/ui/select.tsx +159 -0
- src/components/ui/separator.tsx +29 -0
- src/components/ui/sheet.tsx +138 -0
- src/components/ui/tabs.tsx +53 -0
- src/index.html +14 -0
- src/lib/api-url.ts +25 -0
- src/lib/utils.ts +6 -0
- src/public/favicon.svg +0 -0
- src/server/index.ts +261 -0
- src/server/lib/response-helper.js +85 -0
- src/server/lib/stats-tracker.ts +167 -0
- src/server/plugin-loader.ts +303 -0
- src/server/plugins/data.js +15 -0
- src/server/plugins/downloader/tiktok.js +123 -0
- src/server/static.ts +19 -0
- src/server/types/plugin.ts +72 -0
- src/server/vite.ts +57 -0
- tailwind.config.ts +107 -0
- tsconfig.json +23 -0
- vite.config.ts +23 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
.DS_Store
|
| 4 |
+
vite.config.ts.*
|
| 5 |
+
*.tar.gz
|
Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-alpine
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY package.json ./
|
| 6 |
+
COPY tsconfig.json ./
|
| 7 |
+
COPY vite.config.ts ./
|
| 8 |
+
COPY tailwind.config.ts ./
|
| 9 |
+
COPY postcss.config.js ./
|
| 10 |
+
|
| 11 |
+
RUN npm ci
|
| 12 |
+
|
| 13 |
+
COPY src ./src
|
| 14 |
+
COPY components.json ./
|
| 15 |
+
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
ENV PORT=7860
|
| 20 |
+
ENV NODE_ENV=production
|
| 21 |
+
|
| 22 |
+
CMD ["node", "dist/index.cjs"]
|
_build/script.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { rmSync } from "node:fs";
|
| 2 |
+
import { build as viteBuild } from "vite";
|
| 3 |
+
import { build as esBuild } from "esbuild";
|
| 4 |
+
|
| 5 |
+
async function buildAll() {
|
| 6 |
+
rmSync("dist", { recursive: true, force: true });
|
| 7 |
+
|
| 8 |
+
console.info("[INFO] Building client...");
|
| 9 |
+
await viteBuild();
|
| 10 |
+
|
| 11 |
+
console.info("[INFO] Building server...");
|
| 12 |
+
await esBuild({
|
| 13 |
+
entryPoints: ["src/server/index.ts"],
|
| 14 |
+
platform: "node",
|
| 15 |
+
bundle: true,
|
| 16 |
+
format: "cjs",
|
| 17 |
+
outfile: "dist/index.cjs",
|
| 18 |
+
define: {
|
| 19 |
+
"process.env.NODE_ENV": '"production"',
|
| 20 |
+
},
|
| 21 |
+
minify: true,
|
| 22 |
+
logLevel: "info"
|
| 23 |
+
});
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
buildAll().catch((err) => {
|
| 27 |
+
console.error(err);
|
| 28 |
+
process.exit(1);
|
| 29 |
+
});
|
components.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": false,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "src/client/index.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"aliases": {
|
| 15 |
+
"components": "@/components",
|
| 16 |
+
"utils": "@/lib/utils",
|
| 17 |
+
"ui": "@/components/ui",
|
| 18 |
+
"lib": "@/lib",
|
| 19 |
+
"hooks": "@/hooks"
|
| 20 |
+
},
|
| 21 |
+
"registries": {}
|
| 22 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "ditzzy_api",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"main": "index.js",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "NODE_ENV=development tsx src/server/index.ts",
|
| 7 |
+
"start": "NODE_ENV=production node dist/index.cjs",
|
| 8 |
+
"check": "tsc",
|
| 9 |
+
"build": "tsx _build/script.ts"
|
| 10 |
+
},
|
| 11 |
+
"author": "Ditzzy",
|
| 12 |
+
"license": "MIT",
|
| 13 |
+
"type": "module",
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"@tailwindcss/typography": "^0.5.19",
|
| 16 |
+
"@tailwindcss/vite": "^4.1.18",
|
| 17 |
+
"@types/express": "^5.0.6",
|
| 18 |
+
"@types/node": "^25.0.7",
|
| 19 |
+
"@types/prismjs": "^1.26.5",
|
| 20 |
+
"@types/react": "^19.2.8",
|
| 21 |
+
"@types/react-dom": "^19.2.3",
|
| 22 |
+
"@vitejs/plugin-react": "^5.1.2",
|
| 23 |
+
"autoprefixer": "^10.4.23",
|
| 24 |
+
"chokidar": "^5.0.0",
|
| 25 |
+
"esbuild": "^0.27.2",
|
| 26 |
+
"glob": "^13.0.0",
|
| 27 |
+
"postcss": "^8.4.47",
|
| 28 |
+
"tailwindcss": "^3.4.17",
|
| 29 |
+
"tsx": "^4.21.0",
|
| 30 |
+
"typescript": "^5.9.3",
|
| 31 |
+
"vite": "^7.3.1"
|
| 32 |
+
},
|
| 33 |
+
"dependencies": {
|
| 34 |
+
"@radix-ui/react-dialog": "^1.1.15",
|
| 35 |
+
"@radix-ui/react-select": "^2.2.6",
|
| 36 |
+
"@radix-ui/react-separator": "^1.1.8",
|
| 37 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 38 |
+
"@radix-ui/react-tabs": "^1.1.13",
|
| 39 |
+
"axios": "^1.13.2",
|
| 40 |
+
"class-variance-authority": "^0.7.1",
|
| 41 |
+
"clsx": "^2.1.1",
|
| 42 |
+
"express": "^5.2.1",
|
| 43 |
+
"framer-motion": "^12.26.2",
|
| 44 |
+
"lucide-react": "^0.562.0",
|
| 45 |
+
"prismjs": "^1.30.0",
|
| 46 |
+
"react": "^19.2.3",
|
| 47 |
+
"react-dom": "^19.2.3",
|
| 48 |
+
"react-router-dom": "^7.12.0",
|
| 49 |
+
"recharts": "^3.6.0",
|
| 50 |
+
"tailwind-merge": "^3.4.0",
|
| 51 |
+
"tailwindcss-animate": "^1.0.7"
|
| 52 |
+
}
|
| 53 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
src/client/App.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Route, Routes } from "react-router-dom";
|
| 2 |
+
import { lazy, Suspense } from "react";
|
| 3 |
+
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
| 4 |
+
import { Loader2 } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
const Home = lazy(() => import("./pages/Home"));
|
| 7 |
+
const Docs = lazy(() => import("./pages/Docs"));
|
| 8 |
+
const NotFound = lazy(() => import("./pages/not-found"));
|
| 9 |
+
|
| 10 |
+
function PageLoader() {
|
| 11 |
+
return (
|
| 12 |
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
| 13 |
+
<div className="text-center">
|
| 14 |
+
<Loader2 className="w-8 h-8 text-purple-400 animate-spin mx-auto mb-4" />
|
| 15 |
+
<p className="text-gray-400">Loading...</p>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function App() {
|
| 22 |
+
return (
|
| 23 |
+
<ErrorBoundary>
|
| 24 |
+
<Suspense fallback={<PageLoader />}>
|
| 25 |
+
<Routes>
|
| 26 |
+
<Route path="/" element={<Home />} />
|
| 27 |
+
<Route path="/docs" element={<Docs />} />
|
| 28 |
+
<Route path="*" element={<NotFound />} />
|
| 29 |
+
</Routes>
|
| 30 |
+
</Suspense>
|
| 31 |
+
</ErrorBoundary>
|
| 32 |
+
);
|
| 33 |
+
}
|
src/client/hooks/usePlugin.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
|
| 3 |
+
export interface PluginParameter {
|
| 4 |
+
name: string;
|
| 5 |
+
type: "string" | "number" | "boolean" | "array" | "object";
|
| 6 |
+
required: boolean;
|
| 7 |
+
description: string;
|
| 8 |
+
example?: any;
|
| 9 |
+
default?: any;
|
| 10 |
+
enum?: any[];
|
| 11 |
+
pattern?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export interface PluginResponse {
|
| 15 |
+
status: number;
|
| 16 |
+
description: string;
|
| 17 |
+
example: any;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export interface PluginParameters {
|
| 21 |
+
query?: PluginParameter[];
|
| 22 |
+
body?: PluginParameter[];
|
| 23 |
+
headers?: PluginParameter[];
|
| 24 |
+
path?: PluginParameter[];
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface PluginMetadata {
|
| 28 |
+
name: string;
|
| 29 |
+
description: string;
|
| 30 |
+
version: string;
|
| 31 |
+
category: string[];
|
| 32 |
+
method: string;
|
| 33 |
+
endpoint: string;
|
| 34 |
+
aliases: string[];
|
| 35 |
+
tags?: string[];
|
| 36 |
+
parameters?: PluginParameters;
|
| 37 |
+
responses?: {
|
| 38 |
+
[statusCode: number]: PluginResponse;
|
| 39 |
+
};
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export interface ApiStats {
|
| 43 |
+
totalRequests: number;
|
| 44 |
+
totalSuccess: number;
|
| 45 |
+
totalFailed: number;
|
| 46 |
+
uniqueVisitors: number;
|
| 47 |
+
successRate: string;
|
| 48 |
+
uptime: {
|
| 49 |
+
ms: number;
|
| 50 |
+
hours: number;
|
| 51 |
+
days: number;
|
| 52 |
+
formatted: string;
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface Category {
|
| 57 |
+
name: string;
|
| 58 |
+
count: number;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export function usePlugins() {
|
| 62 |
+
const [plugins, setPlugins] = useState<PluginMetadata[]>([]);
|
| 63 |
+
const [loading, setLoading] = useState(true);
|
| 64 |
+
const [error, setError] = useState<string | null>(null);
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
fetchPlugins();
|
| 68 |
+
}, []);
|
| 69 |
+
|
| 70 |
+
const fetchPlugins = async () => {
|
| 71 |
+
try {
|
| 72 |
+
setLoading(true);
|
| 73 |
+
const response = await fetch("/api/plugins");
|
| 74 |
+
const data = await response.json();
|
| 75 |
+
|
| 76 |
+
if (data.success) {
|
| 77 |
+
setPlugins(data.plugins);
|
| 78 |
+
} else {
|
| 79 |
+
setError("Failed to load plugins");
|
| 80 |
+
}
|
| 81 |
+
} catch (err) {
|
| 82 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 83 |
+
} finally {
|
| 84 |
+
setLoading(false);
|
| 85 |
+
}
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
return { plugins, loading, error, refetch: fetchPlugins };
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export function useStats() {
|
| 92 |
+
const [stats, setStats] = useState<ApiStats | null>(null);
|
| 93 |
+
const [loading, setLoading] = useState(true);
|
| 94 |
+
const [error, setError] = useState<string | null>(null);
|
| 95 |
+
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
fetchStats();
|
| 98 |
+
const interval = setInterval(fetchStats, 30000);
|
| 99 |
+
return () => clearInterval(interval);
|
| 100 |
+
}, []);
|
| 101 |
+
|
| 102 |
+
const fetchStats = async () => {
|
| 103 |
+
try {
|
| 104 |
+
setLoading(true);
|
| 105 |
+
const response = await fetch("/api/stats");
|
| 106 |
+
const data = await response.json();
|
| 107 |
+
|
| 108 |
+
if (data.success) {
|
| 109 |
+
setStats(data.stats.global);
|
| 110 |
+
} else {
|
| 111 |
+
setError("Failed to load stats");
|
| 112 |
+
}
|
| 113 |
+
} catch (err) {
|
| 114 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 115 |
+
} finally {
|
| 116 |
+
setLoading(false);
|
| 117 |
+
}
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
return { stats, loading, error, refetch: fetchStats };
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export function useCategories() {
|
| 124 |
+
const [categories, setCategories] = useState<Category[]>([]);
|
| 125 |
+
const [loading, setLoading] = useState(true);
|
| 126 |
+
const [error, setError] = useState<string | null>(null);
|
| 127 |
+
|
| 128 |
+
useEffect(() => {
|
| 129 |
+
fetchCategories();
|
| 130 |
+
}, []);
|
| 131 |
+
|
| 132 |
+
const fetchCategories = async () => {
|
| 133 |
+
try {
|
| 134 |
+
setLoading(true);
|
| 135 |
+
const response = await fetch("/api/categories");
|
| 136 |
+
const data = await response.json();
|
| 137 |
+
|
| 138 |
+
if (data.success) {
|
| 139 |
+
setCategories(data.categories);
|
| 140 |
+
} else {
|
| 141 |
+
setError("Failed to load categories");
|
| 142 |
+
}
|
| 143 |
+
} catch (err) {
|
| 144 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 145 |
+
} finally {
|
| 146 |
+
setLoading(false);
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
return { categories, loading, error, refetch: fetchCategories };
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
export function usePluginsByCategory(category: string | null) {
|
| 154 |
+
const [plugins, setPlugins] = useState<PluginMetadata[]>([]);
|
| 155 |
+
const [loading, setLoading] = useState(true);
|
| 156 |
+
const [error, setError] = useState<string | null>(null);
|
| 157 |
+
|
| 158 |
+
useEffect(() => {
|
| 159 |
+
if (!category) {
|
| 160 |
+
fetchAllPlugins();
|
| 161 |
+
} else {
|
| 162 |
+
fetchPluginsByCategory(category);
|
| 163 |
+
}
|
| 164 |
+
}, [category]);
|
| 165 |
+
|
| 166 |
+
const fetchAllPlugins = async () => {
|
| 167 |
+
try {
|
| 168 |
+
setLoading(true);
|
| 169 |
+
const response = await fetch("/api/plugins");
|
| 170 |
+
const data = await response.json();
|
| 171 |
+
|
| 172 |
+
if (data.success) {
|
| 173 |
+
setPlugins(data.plugins);
|
| 174 |
+
} else {
|
| 175 |
+
setError("Failed to load plugins");
|
| 176 |
+
}
|
| 177 |
+
} catch (err) {
|
| 178 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 179 |
+
} finally {
|
| 180 |
+
setLoading(false);
|
| 181 |
+
}
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
const fetchPluginsByCategory = async (cat: string) => {
|
| 185 |
+
try {
|
| 186 |
+
setLoading(true);
|
| 187 |
+
const response = await fetch(`/api/plugins/category/${cat}`);
|
| 188 |
+
const data = await response.json();
|
| 189 |
+
|
| 190 |
+
if (data.success) {
|
| 191 |
+
setPlugins(data.plugins);
|
| 192 |
+
} else {
|
| 193 |
+
setError("Failed to load plugins");
|
| 194 |
+
}
|
| 195 |
+
} catch (err) {
|
| 196 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 197 |
+
} finally {
|
| 198 |
+
setLoading(false);
|
| 199 |
+
}
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
return { plugins, loading, error };
|
| 203 |
+
}
|
src/client/index.css
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 2 |
+
|
| 3 |
+
@tailwind base;
|
| 4 |
+
@tailwind components;
|
| 5 |
+
@tailwind utilities;
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
/* Dark mode default */
|
| 9 |
+
--background: 240 10% 4%;
|
| 10 |
+
--foreground: 0 0% 98%;
|
| 11 |
+
|
| 12 |
+
--card: 240 10% 6%;
|
| 13 |
+
--card-foreground: 0 0% 98%;
|
| 14 |
+
|
| 15 |
+
--popover: 240 10% 6%;
|
| 16 |
+
--popover-foreground: 0 0% 98%;
|
| 17 |
+
|
| 18 |
+
--primary: 250 100% 65%;
|
| 19 |
+
--primary-foreground: 0 0% 100%;
|
| 20 |
+
|
| 21 |
+
--secondary: 240 5% 15%;
|
| 22 |
+
--secondary-foreground: 0 0% 98%;
|
| 23 |
+
|
| 24 |
+
--muted: 240 5% 15%;
|
| 25 |
+
--muted-foreground: 240 5% 65%;
|
| 26 |
+
|
| 27 |
+
--accent: 250 100% 65%;
|
| 28 |
+
--accent-foreground: 0 0% 100%;
|
| 29 |
+
|
| 30 |
+
--destructive: 0 62.8% 30.6%;
|
| 31 |
+
--destructive-foreground: 0 0% 98%;
|
| 32 |
+
|
| 33 |
+
--border: 240 5% 15%;
|
| 34 |
+
--input: 240 5% 15%;
|
| 35 |
+
--ring: 250 100% 65%;
|
| 36 |
+
|
| 37 |
+
--radius: 0.75rem;
|
| 38 |
+
|
| 39 |
+
--font-sans: 'Inter', sans-serif;
|
| 40 |
+
--font-display: 'Space Grotesk', sans-serif;
|
| 41 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@layer base {
|
| 45 |
+
* {
|
| 46 |
+
@apply border-border;
|
| 47 |
+
}
|
| 48 |
+
body {
|
| 49 |
+
@apply bg-background text-foreground font-sans antialiased selection:bg-primary/20 selection:text-primary;
|
| 50 |
+
}
|
| 51 |
+
h1, h2, h3, h4, h5, h6 {
|
| 52 |
+
@apply font-display tracking-tight;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* Custom Utilities */
|
| 57 |
+
.glass {
|
| 58 |
+
@apply bg-background/60 backdrop-blur-xl border border-white/5;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.text-gradient {
|
| 62 |
+
@apply bg-clip-text text-transparent bg-gradient-to-r from-white via-white/80 to-white/60;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.text-gradient-primary {
|
| 66 |
+
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary via-purple-400 to-pink-500;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.card-hover {
|
| 70 |
+
@apply transition-all duration-300 hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-1;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* Scrollbar */
|
| 74 |
+
::-webkit-scrollbar {
|
| 75 |
+
width: 8px;
|
| 76 |
+
height: 8px;
|
| 77 |
+
}
|
| 78 |
+
::-webkit-scrollbar-track {
|
| 79 |
+
background: transparent;
|
| 80 |
+
}
|
| 81 |
+
::-webkit-scrollbar-thumb {
|
| 82 |
+
@apply bg-muted rounded-full hover:bg-muted-foreground/50 transition-colors;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Syntax Highlighting overrides */
|
| 86 |
+
code[class*="language-"],
|
| 87 |
+
pre[class*="language-"] {
|
| 88 |
+
@apply text-sm font-mono !bg-transparent !text-sm;
|
| 89 |
+
text-shadow: none !important;
|
| 90 |
+
}
|
src/client/main.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createRoot } from "react-dom/client";
|
| 2 |
+
import React from "react";
|
| 3 |
+
import App from "./App";
|
| 4 |
+
import "./index.css";
|
| 5 |
+
import { BrowserRouter } from "react-router-dom";
|
| 6 |
+
|
| 7 |
+
createRoot(document.getElementById('root')!).render(
|
| 8 |
+
<React.StrictMode>
|
| 9 |
+
<BrowserRouter>
|
| 10 |
+
<App />
|
| 11 |
+
</BrowserRouter>
|
| 12 |
+
</React.StrictMode>
|
| 13 |
+
)
|
src/client/pages/Docs.tsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useMemo } from "react";
|
| 2 |
+
import { Navbar } from "@/components/Navbar";
|
| 3 |
+
import { PluginCard } from "@/components/PluginCard";
|
| 4 |
+
import { Footer } from "@/components/Footer";
|
| 5 |
+
import { StatsCard } from "@/components/StatsCard";
|
| 6 |
+
import { VisitorChart } from "@/components/VisitorChart";
|
| 7 |
+
import { usePlugins, useStats } from "@/client/hooks/usePlugin";
|
| 8 |
+
import { Activity, CheckCircle2, XCircle, TrendingUp, Loader2, Search, X } from "lucide-react";
|
| 9 |
+
import { Input } from "@/components/ui/input";
|
| 10 |
+
import { Badge } from "@/components/ui/badge";
|
| 11 |
+
import { Button } from "@/components/ui/button";
|
| 12 |
+
|
| 13 |
+
export default function Docs() {
|
| 14 |
+
const { plugins, loading: pluginsLoading } = usePlugins();
|
| 15 |
+
const { stats, loading: statsLoading } = useStats();
|
| 16 |
+
|
| 17 |
+
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
| 18 |
+
const [searchQuery, setSearchQuery] = useState("");
|
| 19 |
+
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
| 20 |
+
|
| 21 |
+
const allTags = useMemo(() => {
|
| 22 |
+
const tagsSet = new Set<string>();
|
| 23 |
+
plugins.forEach(plugin => {
|
| 24 |
+
plugin.tags?.forEach(tag => tagsSet.add(tag));
|
| 25 |
+
});
|
| 26 |
+
return Array.from(tagsSet).sort();
|
| 27 |
+
}, [plugins]);
|
| 28 |
+
|
| 29 |
+
const filteredPlugins = useMemo(() => {
|
| 30 |
+
let filtered = plugins;
|
| 31 |
+
|
| 32 |
+
if (selectedCategory) {
|
| 33 |
+
filtered = filtered.filter((p) => p.category.includes(selectedCategory));
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (searchQuery.trim()) {
|
| 37 |
+
const query = searchQuery.toLowerCase();
|
| 38 |
+
filtered = filtered.filter((p) =>
|
| 39 |
+
p.name.toLowerCase().includes(query) ||
|
| 40 |
+
p.description.toLowerCase().includes(query) ||
|
| 41 |
+
p.endpoint.toLowerCase().includes(query) ||
|
| 42 |
+
p.tags?.some(tag => tag.toLowerCase().includes(query))
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (selectedTags.length > 0) {
|
| 47 |
+
filtered = filtered.filter((p) =>
|
| 48 |
+
selectedTags.every(tag => p.tags?.includes(tag))
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return filtered;
|
| 53 |
+
}, [plugins, selectedCategory, searchQuery, selectedTags]);
|
| 54 |
+
|
| 55 |
+
const toggleTag = (tag: string) => {
|
| 56 |
+
setSelectedTags(prev =>
|
| 57 |
+
prev.includes(tag)
|
| 58 |
+
? prev.filter(t => t !== tag)
|
| 59 |
+
: [...prev, tag]
|
| 60 |
+
);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const clearAllFilters = () => {
|
| 64 |
+
setSearchQuery("");
|
| 65 |
+
setSelectedTags([]);
|
| 66 |
+
setSelectedCategory(null);
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div className="min-h-screen bg-background flex flex-col font-sans selection:bg-primary/30">
|
| 71 |
+
{/* Navbar with Categories in Hamburger Menu */}
|
| 72 |
+
<Navbar onCategorySelect={setSelectedCategory} selectedCategory={selectedCategory} />
|
| 73 |
+
|
| 74 |
+
{/* Main Content */}
|
| 75 |
+
<main className="flex-grow">
|
| 76 |
+
<div className="max-w-7xl mx-auto px-4 py-8">
|
| 77 |
+
|
| 78 |
+
{/* Statistics Cards */}
|
| 79 |
+
<div className="mb-8">
|
| 80 |
+
<h2 className="text-2xl font-bold text-white mb-4">API Statistics</h2>
|
| 81 |
+
{statsLoading ? (
|
| 82 |
+
<div className="flex items-center justify-center py-12">
|
| 83 |
+
<Loader2 className="w-8 h-8 text-purple-400 animate-spin" />
|
| 84 |
+
</div>
|
| 85 |
+
) : stats ? (
|
| 86 |
+
<>
|
| 87 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
| 88 |
+
<StatsCard
|
| 89 |
+
title="Total Requests"
|
| 90 |
+
value={stats.totalRequests.toLocaleString()}
|
| 91 |
+
icon={Activity}
|
| 92 |
+
color="purple"
|
| 93 |
+
/>
|
| 94 |
+
<StatsCard
|
| 95 |
+
title="Successful"
|
| 96 |
+
value={stats.totalSuccess.toLocaleString()}
|
| 97 |
+
icon={CheckCircle2}
|
| 98 |
+
color="green"
|
| 99 |
+
/>
|
| 100 |
+
<StatsCard
|
| 101 |
+
title="Failed"
|
| 102 |
+
value={stats.totalFailed.toLocaleString()}
|
| 103 |
+
icon={XCircle}
|
| 104 |
+
color="red"
|
| 105 |
+
/>
|
| 106 |
+
<StatsCard
|
| 107 |
+
title="Success Rate"
|
| 108 |
+
value={`${stats.successRate}%`}
|
| 109 |
+
icon={TrendingUp}
|
| 110 |
+
color="blue"
|
| 111 |
+
/>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Visitor Chart */}
|
| 115 |
+
<VisitorChart />
|
| 116 |
+
</>
|
| 117 |
+
) : (
|
| 118 |
+
<div className="text-sm text-gray-500">Failed to load statistics</div>
|
| 119 |
+
)}
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* Search and Filter Section */}
|
| 123 |
+
<div className="mb-6 space-y-4">
|
| 124 |
+
{/* Search Bar */}
|
| 125 |
+
<div className="relative">
|
| 126 |
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
| 127 |
+
<Input
|
| 128 |
+
type="text"
|
| 129 |
+
placeholder="Search endpoints, descriptions, or tags..."
|
| 130 |
+
value={searchQuery}
|
| 131 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 132 |
+
className="pl-10 bg-white/[0.02] border-white/10 text-white placeholder:text-gray-500 focus:border-purple-500 h-12"
|
| 133 |
+
/>
|
| 134 |
+
{searchQuery && (
|
| 135 |
+
<button
|
| 136 |
+
onClick={() => setSearchQuery("")}
|
| 137 |
+
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
| 138 |
+
>
|
| 139 |
+
<X className="w-4 h-4" />
|
| 140 |
+
</button>
|
| 141 |
+
)}
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
{/* Tags Filter */}
|
| 145 |
+
{allTags.length > 0 && (
|
| 146 |
+
<div>
|
| 147 |
+
<div className="flex items-center justify-between mb-2">
|
| 148 |
+
<h3 className="text-sm font-semibold text-gray-400">Filter by Tags</h3>
|
| 149 |
+
{selectedTags.length > 0 && (
|
| 150 |
+
<button
|
| 151 |
+
onClick={() => setSelectedTags([])}
|
| 152 |
+
className="text-xs text-purple-400 hover:text-purple-300"
|
| 153 |
+
>
|
| 154 |
+
Clear tags
|
| 155 |
+
</button>
|
| 156 |
+
)}
|
| 157 |
+
</div>
|
| 158 |
+
<div className="flex flex-wrap gap-2">
|
| 159 |
+
{allTags.map((tag) => (
|
| 160 |
+
<Badge
|
| 161 |
+
key={tag}
|
| 162 |
+
onClick={() => toggleTag(tag)}
|
| 163 |
+
className={`cursor-pointer transition-colors ${
|
| 164 |
+
selectedTags.includes(tag)
|
| 165 |
+
? "bg-purple-500/30 text-purple-300 border-purple-500 hover:bg-purple-500/40"
|
| 166 |
+
: "bg-white/5 text-gray-400 border-white/10 hover:bg-white/10"
|
| 167 |
+
} border`}
|
| 168 |
+
>
|
| 169 |
+
{tag}
|
| 170 |
+
</Badge>
|
| 171 |
+
))}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
|
| 176 |
+
{/* Active Filters Summary */}
|
| 177 |
+
{(selectedCategory || searchQuery || selectedTags.length > 0) && (
|
| 178 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 179 |
+
<span className="text-sm text-gray-400">Active filters:</span>
|
| 180 |
+
{selectedCategory && (
|
| 181 |
+
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/50">
|
| 182 |
+
Category: {selectedCategory}
|
| 183 |
+
</Badge>
|
| 184 |
+
)}
|
| 185 |
+
{searchQuery && (
|
| 186 |
+
<Badge className="bg-green-500/20 text-green-400 border-green-500/50">
|
| 187 |
+
Search: "{searchQuery}"
|
| 188 |
+
</Badge>
|
| 189 |
+
)}
|
| 190 |
+
{selectedTags.map(tag => (
|
| 191 |
+
<Badge key={tag} className="bg-purple-500/20 text-purple-400 border-purple-500/50">
|
| 192 |
+
Tag: {tag}
|
| 193 |
+
</Badge>
|
| 194 |
+
))}
|
| 195 |
+
<Button
|
| 196 |
+
variant="ghost"
|
| 197 |
+
size="sm"
|
| 198 |
+
onClick={clearAllFilters}
|
| 199 |
+
className="text-xs text-gray-400 hover:text-white"
|
| 200 |
+
>
|
| 201 |
+
Clear all
|
| 202 |
+
</Button>
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
{/* Results Count */}
|
| 208 |
+
<div className="mb-6">
|
| 209 |
+
<h2 className="text-2xl font-bold text-white capitalize">
|
| 210 |
+
{selectedCategory ? `${selectedCategory} Endpoints` : "All Endpoints"}
|
| 211 |
+
</h2>
|
| 212 |
+
<p className="text-gray-400 text-sm mt-1">
|
| 213 |
+
Showing {filteredPlugins.length} of {plugins.length} endpoint{filteredPlugins.length !== 1 ? 's' : ''}
|
| 214 |
+
</p>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{/* Plugins List */}
|
| 218 |
+
<div className="space-y-6">
|
| 219 |
+
{pluginsLoading ? (
|
| 220 |
+
<div className="flex items-center justify-center py-20">
|
| 221 |
+
<Loader2 className="w-8 h-8 text-purple-400 animate-spin" />
|
| 222 |
+
</div>
|
| 223 |
+
) : filteredPlugins.length > 0 ? (
|
| 224 |
+
filteredPlugins.map((plugin) => (
|
| 225 |
+
<PluginCard key={plugin.endpoint} plugin={plugin} />
|
| 226 |
+
))
|
| 227 |
+
) : (
|
| 228 |
+
<div className="text-center py-20">
|
| 229 |
+
<div className="text-gray-400 text-lg mb-2">No endpoints found</div>
|
| 230 |
+
<div className="text-gray-600 text-sm mb-4">
|
| 231 |
+
{searchQuery || selectedTags.length > 0
|
| 232 |
+
? "Try adjusting your search or filters"
|
| 233 |
+
: selectedCategory
|
| 234 |
+
? "No plugins available in this category"
|
| 235 |
+
: "No plugins available"}
|
| 236 |
+
</div>
|
| 237 |
+
{(searchQuery || selectedTags.length > 0 || selectedCategory) && (
|
| 238 |
+
<Button
|
| 239 |
+
onClick={clearAllFilters}
|
| 240 |
+
variant="outline"
|
| 241 |
+
className="border-white/10 text-purple-400 hover:bg-purple-500/10"
|
| 242 |
+
>
|
| 243 |
+
Clear all filters
|
| 244 |
+
</Button>
|
| 245 |
+
)}
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
</div>
|
| 251 |
+
</main>
|
| 252 |
+
|
| 253 |
+
<Footer />
|
| 254 |
+
</div>
|
| 255 |
+
);
|
| 256 |
+
}
|
src/client/pages/Home.tsx
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CodeSnippet } from "@/components/CodeSnippet";
|
| 2 |
+
import { Footer } from "@/components/Footer";
|
| 3 |
+
import { Button } from "@/components/ui/button";
|
| 4 |
+
import { motion } from "framer-motion";
|
| 5 |
+
import { DollarSign, Code2, Key, ChevronDown } from "lucide-react";
|
| 6 |
+
import { Link } from "react-router-dom";
|
| 7 |
+
import { useState } from "react";
|
| 8 |
+
import { getBaseUrl } from "@/lib/api-url";
|
| 9 |
+
|
| 10 |
+
export default function Home() {
|
| 11 |
+
const exampleFetchCode = `// Completely easy to use API!
|
| 12 |
+
const res = await fetch("${getBaseUrl()}/api/data");
|
| 13 |
+
const json = await res.json();
|
| 14 |
+
|
| 15 |
+
console.log(json)
|
| 16 |
+
// Check out your console!
|
| 17 |
+
`;
|
| 18 |
+
|
| 19 |
+
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
| 20 |
+
|
| 21 |
+
const faqs = [
|
| 22 |
+
{
|
| 23 |
+
question: "Do I need an API key to use DitzzyAPI?",
|
| 24 |
+
answer: "No! DitzzyAPI is completely free and doesn't require any API key. Just start making requests right away."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
question: "Is there really no usage limit?",
|
| 28 |
+
answer: "DitzzyAPI is free and unlimited for everyone. However, we implement rate limiting to ensure fair usage and keep our servers stable for all users."
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
question: "What are the rate limits?",
|
| 32 |
+
answer: "We apply reasonable rate limits per IP address to prevent abuse and maintain server stability. Normal usage patterns are well within these limits."
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
question: "How do you keep the service free?",
|
| 36 |
+
answer: "We're passionate about supporting the developer community. Rate limits help us manage costs while keeping the service free for everyone."
|
| 37 |
+
}
|
| 38 |
+
];
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="min-h-screen bg-background flex flex-col font-sans selection:bg-primary/30">
|
| 42 |
+
<main className="flex-grow pt-8">
|
| 43 |
+
{/* Hero Section */}
|
| 44 |
+
<section className="relative overflow-hidden py-24 sm:py-32">
|
| 45 |
+
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-primary/20 blur-[120px] rounded-full opacity-50 pointer-events-none" />
|
| 46 |
+
<div className="absolute bottom-0 right-0 w-[800px] h-[600px] bg-purple-500/10 blur-[100px] rounded-full opacity-30 pointer-events-none" />
|
| 47 |
+
|
| 48 |
+
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col items-center text-center">
|
| 49 |
+
<motion.div
|
| 50 |
+
initial={{ opacity: 0, y: 20 }}
|
| 51 |
+
animate={{ opacity: 1, y: 0 }}
|
| 52 |
+
transition={{ duration: 0.5 }}
|
| 53 |
+
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-xs font-medium text-primary mb-8 backdrop-blur-md"
|
| 54 |
+
>
|
| 55 |
+
<span className="relative flex h-2 w-2">
|
| 56 |
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
| 57 |
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
| 58 |
+
</span>
|
| 59 |
+
<a href="https://github.com/OhMyDitzzy/Yuki">DitzzyAPI has an official bot script, Click here to check now!</a>
|
| 60 |
+
</motion.div>
|
| 61 |
+
|
| 62 |
+
<motion.h1
|
| 63 |
+
initial={{ opacity: 0, y: 20 }}
|
| 64 |
+
animate={{ opacity: 1, y: 0 }}
|
| 65 |
+
transition={{ duration: 0.5, delay: 0.1 }}
|
| 66 |
+
className="text-5xl sm:text-7xl font-display font-bold tracking-tight text-white mb-6 max-w-4xl"
|
| 67 |
+
>
|
| 68 |
+
Build faster with the <br />
|
| 69 |
+
<span className="text-gradient-primary">ultimate developer API</span>
|
| 70 |
+
</motion.h1>
|
| 71 |
+
|
| 72 |
+
<motion.p
|
| 73 |
+
initial={{ opacity: 0, y: 20 }}
|
| 74 |
+
animate={{ opacity: 1, y: 0 }}
|
| 75 |
+
transition={{ duration: 0.5, delay: 0.2 }}
|
| 76 |
+
className="text-lg sm:text-xl text-muted-foreground max-w-2xl mb-10 leading-relaxed"
|
| 77 |
+
>
|
| 78 |
+
Free, unlimited, open-source API, and no API key required. Start building instantly with our
|
| 79 |
+
developer-friendly API.
|
| 80 |
+
</motion.p>
|
| 81 |
+
|
| 82 |
+
<motion.div
|
| 83 |
+
initial={{ opacity: 0, y: 20 }}
|
| 84 |
+
animate={{ opacity: 1, y: 0 }}
|
| 85 |
+
transition={{ duration: 0.5, delay: 0.3 }}
|
| 86 |
+
className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto"
|
| 87 |
+
>
|
| 88 |
+
<Link to="/docs">
|
| 89 |
+
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base rounded-xl">
|
| 90 |
+
View Documentation
|
| 91 |
+
</Button>
|
| 92 |
+
</Link>
|
| 93 |
+
<a href="https://github.com/OhMyDitzzy/DitzzyAPI" target="_blank" rel="noopener noreferrer">
|
| 94 |
+
<Button variant="outline" size="lg" className="w-full sm:w-auto h-12 px-8 text-base rounded-xl border-white/10 hover:bg-white/5">
|
| 95 |
+
View on GitHub
|
| 96 |
+
</Button>
|
| 97 |
+
</a>
|
| 98 |
+
</motion.div>
|
| 99 |
+
|
| 100 |
+
<div className="mt-20 w-full flex justify-center">
|
| 101 |
+
<CodeSnippet
|
| 102 |
+
filename="api_example.ts"
|
| 103 |
+
language="typescript"
|
| 104 |
+
code={exampleFetchCode}
|
| 105 |
+
delay={0.5}
|
| 106 |
+
showLineNumbers={true}
|
| 107 |
+
copyable={true}
|
| 108 |
+
/>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</section>
|
| 112 |
+
|
| 113 |
+
{/* Stats Section */}
|
| 114 |
+
<section className="py-16 border-b border-white/5">
|
| 115 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 116 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
| 117 |
+
{[
|
| 118 |
+
{ value: "100%", label: "Free Forever" },
|
| 119 |
+
{ value: "No Keys", label: "Just Start Coding" },
|
| 120 |
+
{ value: "<100ms", label: "Avg Response" },
|
| 121 |
+
{ value: "24/7", label: "Always Online" }
|
| 122 |
+
].map((stat, i) => (
|
| 123 |
+
<motion.div
|
| 124 |
+
key={i}
|
| 125 |
+
initial={{ opacity: 0, y: 20 }}
|
| 126 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 127 |
+
viewport={{ once: true }}
|
| 128 |
+
transition={{ delay: i * 0.1 }}
|
| 129 |
+
className="text-center"
|
| 130 |
+
>
|
| 131 |
+
<div className="text-4xl sm:text-5xl font-bold text-gradient-primary mb-2">
|
| 132 |
+
{stat.value}
|
| 133 |
+
</div>
|
| 134 |
+
<div className="text-sm text-muted-foreground">
|
| 135 |
+
{stat.label}
|
| 136 |
+
</div>
|
| 137 |
+
</motion.div>
|
| 138 |
+
))}
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</section>
|
| 142 |
+
|
| 143 |
+
{/* Features Section */}
|
| 144 |
+
<section className="py-24 bg-black/20 border-b border-white/5">
|
| 145 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 146 |
+
<motion.div
|
| 147 |
+
initial={{ opacity: 0, y: 20 }}
|
| 148 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 149 |
+
viewport={{ once: true }}
|
| 150 |
+
className="text-center mb-16"
|
| 151 |
+
>
|
| 152 |
+
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
| 153 |
+
Why choose DitzzyAPI?
|
| 154 |
+
</h2>
|
| 155 |
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
| 156 |
+
No registration, no API keys, no hidden fees. Just pure simplicity.
|
| 157 |
+
</p>
|
| 158 |
+
</motion.div>
|
| 159 |
+
|
| 160 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
| 161 |
+
{[
|
| 162 |
+
{ icon: Key, title: "No API Key Needed", desc: "Start using immediately. No sign-ups, no authentication hassles." },
|
| 163 |
+
{ icon: DollarSign, title: "Free Unlimited", desc: "Completely free with unlimited requests. No credit card required." },
|
| 164 |
+
{ icon: Code2, title: "Developer Friendly", desc: "Simple endpoints, clear documentation, and 24/7 availability." }
|
| 165 |
+
].map((feature, i) => (
|
| 166 |
+
<motion.div
|
| 167 |
+
key={i}
|
| 168 |
+
initial={{ opacity: 0, y: 20 }}
|
| 169 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 170 |
+
viewport={{ once: true }}
|
| 171 |
+
transition={{ delay: i * 0.1 }}
|
| 172 |
+
className="p-6 rounded-2xl bg-white/5 border border-white/5 hover:border-primary/50 transition-colors group"
|
| 173 |
+
>
|
| 174 |
+
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
| 175 |
+
<feature.icon className="w-6 h-6 text-primary" />
|
| 176 |
+
</div>
|
| 177 |
+
<h3 className="text-xl font-bold text-white mb-2">{feature.title}</h3>
|
| 178 |
+
<p className="text-muted-foreground">{feature.desc}</p>
|
| 179 |
+
</motion.div>
|
| 180 |
+
))}
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
</section>
|
| 184 |
+
|
| 185 |
+
{/* FAQ Section */}
|
| 186 |
+
<section className="py-24 border-b border-white/5">
|
| 187 |
+
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 188 |
+
<motion.div
|
| 189 |
+
initial={{ opacity: 0, y: 20 }}
|
| 190 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 191 |
+
viewport={{ once: true }}
|
| 192 |
+
className="text-center mb-16"
|
| 193 |
+
>
|
| 194 |
+
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
| 195 |
+
Frequently Asked Questions
|
| 196 |
+
</h2>
|
| 197 |
+
<p className="text-lg text-muted-foreground">
|
| 198 |
+
Everything you need to know about DitzzyAPI
|
| 199 |
+
</p>
|
| 200 |
+
</motion.div>
|
| 201 |
+
|
| 202 |
+
<div className="space-y-4">
|
| 203 |
+
{faqs.map((faq, i) => (
|
| 204 |
+
<motion.div
|
| 205 |
+
key={i}
|
| 206 |
+
initial={{ opacity: 0, y: 20 }}
|
| 207 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 208 |
+
viewport={{ once: true }}
|
| 209 |
+
transition={{ delay: i * 0.1 }}
|
| 210 |
+
className="rounded-xl bg-white/5 border border-white/5 overflow-hidden"
|
| 211 |
+
>
|
| 212 |
+
<button
|
| 213 |
+
onClick={() => setOpenFaq(openFaq === i ? null : i)}
|
| 214 |
+
className="w-full px-6 py-5 flex items-center justify-between text-left hover:bg-white/5 transition-colors"
|
| 215 |
+
>
|
| 216 |
+
<span className="text-lg font-semibold text-white">
|
| 217 |
+
{faq.question}
|
| 218 |
+
</span>
|
| 219 |
+
<ChevronDown
|
| 220 |
+
className={`w-5 h-5 text-primary transition-transform ${
|
| 221 |
+
openFaq === i ? "rotate-180" : ""
|
| 222 |
+
}`}
|
| 223 |
+
/>
|
| 224 |
+
</button>
|
| 225 |
+
{openFaq === i && (
|
| 226 |
+
<div className="px-6 pb-5 text-muted-foreground">
|
| 227 |
+
{faq.answer}
|
| 228 |
+
</div>
|
| 229 |
+
)}
|
| 230 |
+
</motion.div>
|
| 231 |
+
))}
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
</section>
|
| 235 |
+
|
| 236 |
+
{/* CTA Section */}
|
| 237 |
+
<section className="py-24 relative overflow-hidden">
|
| 238 |
+
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 to-transparent pointer-events-none blur-[120px] opacity-50" />
|
| 239 |
+
<div className="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
| 240 |
+
<motion.div
|
| 241 |
+
initial={{ opacity: 0, y: 20 }}
|
| 242 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 243 |
+
viewport={{ once: true }}
|
| 244 |
+
>
|
| 245 |
+
<h2 className="text-3xl sm:text-5xl font-bold text-white mb-6">
|
| 246 |
+
Ready to get started?
|
| 247 |
+
</h2>
|
| 248 |
+
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
|
| 249 |
+
Join thousands of developers using DitzzyAPI. No registration needed,
|
| 250 |
+
just pick an endpoint and start coding!
|
| 251 |
+
</p>
|
| 252 |
+
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
| 253 |
+
<Link to="/docs">
|
| 254 |
+
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base rounded-xl">
|
| 255 |
+
Start Building Now
|
| 256 |
+
</Button>
|
| 257 |
+
</Link>
|
| 258 |
+
</div>
|
| 259 |
+
</motion.div>
|
| 260 |
+
</div>
|
| 261 |
+
</section>
|
| 262 |
+
</main>
|
| 263 |
+
|
| 264 |
+
<Footer />
|
| 265 |
+
</div>
|
| 266 |
+
);
|
| 267 |
+
}
|
src/client/pages/not-found.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "@/components/ui/button";
|
| 2 |
+
import { AlertCircle } from "lucide-react";
|
| 3 |
+
import { Link } from "react-router-dom"
|
| 4 |
+
|
| 5 |
+
export default function NotFound() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-background text-center p-4">
|
| 8 |
+
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mb-6">
|
| 9 |
+
<AlertCircle className="w-8 h-8 text-red-500" />
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<h1 className="text-4xl font-display font-bold text-white mb-2">404 Page Not Found</h1>
|
| 13 |
+
<p className="text-muted-foreground max-w-md mb-8">
|
| 14 |
+
The page you are looking for doesn't exist or has been moved.
|
| 15 |
+
</p>
|
| 16 |
+
<Link to="/">
|
| 17 |
+
<Button>Go back</Button>
|
| 18 |
+
</Link>
|
| 19 |
+
</div>
|
| 20 |
+
)
|
| 21 |
+
}
|
src/components/CodeBlock.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
import Prism from "prismjs";
|
| 3 |
+
import "prismjs/themes/prism-tomorrow.css";
|
| 4 |
+
import "prismjs/components/prism-bash";
|
| 5 |
+
import "prismjs/components/prism-javascript";
|
| 6 |
+
import "prismjs/components/prism-typescript";
|
| 7 |
+
import "prismjs/components/prism-json";
|
| 8 |
+
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { Copy, Check } from "lucide-react";
|
| 10 |
+
import { useState } from "react";
|
| 11 |
+
|
| 12 |
+
interface CodeBlockProps {
|
| 13 |
+
code: string;
|
| 14 |
+
language: "bash" | "javascript" | "typescript" | "json";
|
| 15 |
+
showCopy?: boolean;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function CodeBlock({ code, language, showCopy = true }: CodeBlockProps) {
|
| 19 |
+
const codeRef = useRef<HTMLElement>(null);
|
| 20 |
+
const [copied, setCopied] = useState(false);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
if (codeRef.current) {
|
| 24 |
+
Prism.highlightElement(codeRef.current);
|
| 25 |
+
}
|
| 26 |
+
}, [code, language]);
|
| 27 |
+
|
| 28 |
+
const handleCopy = () => {
|
| 29 |
+
navigator.clipboard.writeText(code);
|
| 30 |
+
setCopied(true);
|
| 31 |
+
setTimeout(() => setCopied(false), 2000);
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="relative group">
|
| 36 |
+
{showCopy && (
|
| 37 |
+
<Button
|
| 38 |
+
variant="ghost"
|
| 39 |
+
size="sm"
|
| 40 |
+
onClick={handleCopy}
|
| 41 |
+
className="absolute top-2 right-2 h-8 opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 z-10"
|
| 42 |
+
>
|
| 43 |
+
{copied ? (
|
| 44 |
+
<>
|
| 45 |
+
<Check className="w-3 h-3 mr-1" />
|
| 46 |
+
Copied
|
| 47 |
+
</>
|
| 48 |
+
) : (
|
| 49 |
+
<>
|
| 50 |
+
<Copy className="w-3 h-3 mr-1" />
|
| 51 |
+
Copy
|
| 52 |
+
</>
|
| 53 |
+
)}
|
| 54 |
+
</Button>
|
| 55 |
+
)}
|
| 56 |
+
<pre className="!bg-[#1e1e1e] !border !border-white/10 !rounded-lg !p-4 !m-0 overflow-x-auto">
|
| 57 |
+
<code ref={codeRef} className={`language-${language} !text-sm`}>
|
| 58 |
+
{code}
|
| 59 |
+
</code>
|
| 60 |
+
</pre>
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
src/components/CodeSnippet.tsx
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react"
|
| 2 |
+
import { motion } from "framer-motion"
|
| 3 |
+
import { Copy, Check } from "lucide-react"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import Prism from "prismjs"
|
| 6 |
+
import "prismjs/themes/prism-tomorrow.css"
|
| 7 |
+
import "prismjs/components/prism-javascript"
|
| 8 |
+
import "prismjs/components/prism-typescript"
|
| 9 |
+
import "prismjs/components/prism-python"
|
| 10 |
+
import "prismjs/components/prism-jsx"
|
| 11 |
+
import "prismjs/components/prism-tsx"
|
| 12 |
+
import "prismjs/components/prism-json"
|
| 13 |
+
import "prismjs/components/prism-bash"
|
| 14 |
+
import "prismjs/components/prism-markup"
|
| 15 |
+
import "prismjs/components/prism-css"
|
| 16 |
+
|
| 17 |
+
interface CodeSnippetProps {
|
| 18 |
+
filename?: string
|
| 19 |
+
language?: string
|
| 20 |
+
code: string
|
| 21 |
+
delay?: number
|
| 22 |
+
className?: string
|
| 23 |
+
showLineNumbers?: boolean
|
| 24 |
+
copyable?: boolean
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const LANGUAGE_MAP: Record<string, string> = {
|
| 28 |
+
js: "javascript",
|
| 29 |
+
javascript: "javascript",
|
| 30 |
+
ts: "typescript",
|
| 31 |
+
typescript: "typescript",
|
| 32 |
+
py: "python",
|
| 33 |
+
python: "python",
|
| 34 |
+
jsx: "jsx",
|
| 35 |
+
tsx: "tsx",
|
| 36 |
+
json: "json",
|
| 37 |
+
bash: "bash",
|
| 38 |
+
sh: "bash",
|
| 39 |
+
html: "markup",
|
| 40 |
+
css: "css",
|
| 41 |
+
sql: "sql",
|
| 42 |
+
go: "go",
|
| 43 |
+
rust: "rust",
|
| 44 |
+
java: "java",
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
export function CodeSnippet({
|
| 48 |
+
filename = "example.js",
|
| 49 |
+
language = "javascript",
|
| 50 |
+
code,
|
| 51 |
+
delay = 0.5,
|
| 52 |
+
className = "",
|
| 53 |
+
showLineNumbers = false,
|
| 54 |
+
copyable = true,
|
| 55 |
+
}: CodeSnippetProps) {
|
| 56 |
+
const [copied, setCopied] = useState(false);
|
| 57 |
+
const prismLanguage = LANGUAGE_MAP[language.toLowerCase()] || language;
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
// Highlight code whenever component mounts or code changes
|
| 61 |
+
Prism.highlightAll();
|
| 62 |
+
}, [code, language]);
|
| 63 |
+
|
| 64 |
+
const handleCopy = async () => {
|
| 65 |
+
try {
|
| 66 |
+
await navigator.clipboard.writeText(code);
|
| 67 |
+
setCopied(true);
|
| 68 |
+
setTimeout(() => setCopied(false), 2000);
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.error("Failed to copy:", err);
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
const displayLanguage = language.toUpperCase();
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<motion.div
|
| 78 |
+
initial={{ opacity: 0, y: 40 }}
|
| 79 |
+
animate={{ opacity: 1, y: 0 }}
|
| 80 |
+
transition={{ duration: 0.7, delay }}
|
| 81 |
+
className={`group relative w-full max-w-3xl rounded-xl overflow-hidden border border-white/10 shadow-2xl bg-[#0d1117]/80 backdrop-blur-sm ${className}`}
|
| 82 |
+
>
|
| 83 |
+
{/* Header */}
|
| 84 |
+
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 bg-gradient-to-r from-white/[0.03] to-white/[0.01]">
|
| 85 |
+
<div className="flex items-center gap-2">
|
| 86 |
+
<div className="flex gap-1.5">
|
| 87 |
+
<div className="w-3 h-3 rounded-full bg-red-500/80" />
|
| 88 |
+
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
| 89 |
+
<div className="w-3 h-3 rounded-full bg-green-500/80" />
|
| 90 |
+
</div>
|
| 91 |
+
<div className="text-xs text-muted-foreground ml-2 font-mono truncate">
|
| 92 |
+
{filename}
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className="flex items-center gap-3">
|
| 97 |
+
{copyable && (
|
| 98 |
+
<Button
|
| 99 |
+
variant="ghost"
|
| 100 |
+
size="sm"
|
| 101 |
+
onClick={handleCopy}
|
| 102 |
+
className="h-7 px-2 hover:bg-white/10 transition-colors"
|
| 103 |
+
>
|
| 104 |
+
{copied ? (
|
| 105 |
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
| 106 |
+
) : (
|
| 107 |
+
<Copy className="w-3.5 h-3.5 text-gray-400" />
|
| 108 |
+
)}
|
| 109 |
+
<span className="ml-1 text-xs">
|
| 110 |
+
{copied ? "Copied!" : "Copy"}
|
| 111 |
+
</span>
|
| 112 |
+
</Button>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* Code Area */}
|
| 118 |
+
<div className="relative">
|
| 119 |
+
<pre className={`font-mono text-sm leading-relaxed m-0 overflow-x-auto p-6 ${showLineNumbers ? 'line-numbers' : ''}`}>
|
| 120 |
+
<code className={`language-${prismLanguage}`}>
|
| 121 |
+
{code}
|
| 122 |
+
</code>
|
| 123 |
+
</pre>
|
| 124 |
+
|
| 125 |
+
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#0d1117] to-transparent pointer-events-none" />
|
| 126 |
+
</div>
|
| 127 |
+
</motion.div>
|
| 128 |
+
);
|
| 129 |
+
}
|
src/components/ErrorBoundary.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { Component, ReactNode } from "react";
|
| 2 |
+
import { Button } from "./ui/button";
|
| 3 |
+
import { AlertTriangle, RefreshCw, Home, Code } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
children: ReactNode;
|
| 7 |
+
fallback?: ReactNode;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
interface State {
|
| 11 |
+
hasError: boolean;
|
| 12 |
+
error: Error | null;
|
| 13 |
+
errorInfo: React.ErrorInfo | null;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export class ErrorBoundary extends Component<Props, State> {
|
| 17 |
+
constructor(props: Props) {
|
| 18 |
+
super(props);
|
| 19 |
+
this.state = {
|
| 20 |
+
hasError: false,
|
| 21 |
+
error: null,
|
| 22 |
+
errorInfo: null,
|
| 23 |
+
};
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
static getDerivedStateFromError(error: Error): Partial<State> {
|
| 27 |
+
return { hasError: true };
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
| 31 |
+
console.error("Error caught by boundary:", error, errorInfo);
|
| 32 |
+
this.setState({
|
| 33 |
+
error,
|
| 34 |
+
errorInfo,
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
handleReset = () => {
|
| 39 |
+
this.setState({
|
| 40 |
+
hasError: false,
|
| 41 |
+
error: null,
|
| 42 |
+
errorInfo: null,
|
| 43 |
+
});
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
render() {
|
| 47 |
+
if (this.state.hasError) {
|
| 48 |
+
if (this.props.fallback) {
|
| 49 |
+
return this.props.fallback;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="min-h-screen flex items-center justify-center bg-background px-4 py-8">
|
| 54 |
+
<div className="max-w-3xl w-full">
|
| 55 |
+
{/* Main Error Card */}
|
| 56 |
+
<div className="bg-white/[0.02] border border-white/10 rounded-xl overflow-hidden backdrop-blur-sm">
|
| 57 |
+
{/* Header with Gradient */}
|
| 58 |
+
<div className="bg-gradient-to-r from-red-500/20 via-orange-500/20 to-yellow-500/20 border-b border-white/10 p-8">
|
| 59 |
+
<div className="flex items-start gap-4">
|
| 60 |
+
{/* Animated Icon */}
|
| 61 |
+
<div className="flex-shrink-0">
|
| 62 |
+
<div className="w-16 h-16 bg-red-500/20 rounded-2xl flex items-center justify-center border border-red-500/30 backdrop-blur-sm animate-pulse">
|
| 63 |
+
<AlertTriangle className="w-8 h-8 text-red-400" />
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div className="flex-1 min-w-0">
|
| 68 |
+
<h1 className="text-3xl font-bold text-white mb-2 flex items-center gap-2">
|
| 69 |
+
Oops! Something went wrong
|
| 70 |
+
</h1>
|
| 71 |
+
<p className="text-gray-400 text-base">
|
| 72 |
+
Don't worry, your data is safe. The error has been logged and we'll look into it.
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* Error Details */}
|
| 79 |
+
<div className="p-8 space-y-6">
|
| 80 |
+
{this.state.error && (
|
| 81 |
+
<div className="space-y-4">
|
| 82 |
+
{/* Error Message */}
|
| 83 |
+
<div>
|
| 84 |
+
<h3 className="text-sm font-semibold text-gray-400 mb-2 flex items-center gap-2">
|
| 85 |
+
<Code className="w-4 h-4" />
|
| 86 |
+
Error Message
|
| 87 |
+
</h3>
|
| 88 |
+
<div className="bg-black/50 border border-red-500/30 rounded-lg p-4 overflow-x-auto">
|
| 89 |
+
<pre className="text-sm text-red-300 whitespace-pre-wrap break-words">
|
| 90 |
+
{this.state.error.toString()}
|
| 91 |
+
</pre>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* Stack Trace (Dev Only) */}
|
| 96 |
+
{import.meta.env.DEV && this.state.errorInfo && (
|
| 97 |
+
<details className="group">
|
| 98 |
+
<summary className="cursor-pointer text-sm font-semibold text-gray-400 hover:text-gray-300 transition-colors select-none flex items-center gap-2 mb-2">
|
| 99 |
+
<svg
|
| 100 |
+
className="w-4 h-4 transition-transform group-open:rotate-90"
|
| 101 |
+
fill="none"
|
| 102 |
+
stroke="currentColor"
|
| 103 |
+
viewBox="0 0 24 24"
|
| 104 |
+
>
|
| 105 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
| 106 |
+
</svg>
|
| 107 |
+
Stack Trace (Development Mode)
|
| 108 |
+
</summary>
|
| 109 |
+
<div className="bg-black/50 border border-white/10 rounded-lg p-4 overflow-x-auto">
|
| 110 |
+
<pre className="text-xs text-gray-400 whitespace-pre-wrap">
|
| 111 |
+
{this.state.errorInfo.componentStack}
|
| 112 |
+
</pre>
|
| 113 |
+
</div>
|
| 114 |
+
</details>
|
| 115 |
+
)}
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
|
| 119 |
+
{/* Action Buttons */}
|
| 120 |
+
<div className="flex flex-wrap gap-3 pt-4">
|
| 121 |
+
<Button
|
| 122 |
+
onClick={this.handleReset}
|
| 123 |
+
className="bg-purple-500 hover:bg-purple-600 text-white gap-2"
|
| 124 |
+
>
|
| 125 |
+
<RefreshCw className="w-4 h-4" />
|
| 126 |
+
Try Again
|
| 127 |
+
</Button>
|
| 128 |
+
<Button
|
| 129 |
+
onClick={() => window.location.reload()}
|
| 130 |
+
variant="outline"
|
| 131 |
+
className="border-white/10 text-gray-300 hover:bg-white/5 gap-2"
|
| 132 |
+
>
|
| 133 |
+
<RefreshCw className="w-4 h-4" />
|
| 134 |
+
Refresh Page
|
| 135 |
+
</Button>
|
| 136 |
+
<Button
|
| 137 |
+
onClick={() => (window.location.href = "/")}
|
| 138 |
+
variant="outline"
|
| 139 |
+
className="border-white/10 text-gray-300 hover:bg-white/5 gap-2"
|
| 140 |
+
>
|
| 141 |
+
<Home className="w-4 h-4" />
|
| 142 |
+
Go Home
|
| 143 |
+
</Button>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
{/* Help Text */}
|
| 147 |
+
<div className="pt-4 border-t border-white/10">
|
| 148 |
+
<p className="text-xs text-gray-500">
|
| 149 |
+
If this problem persists, please contact support or check the console for more details.
|
| 150 |
+
</p>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
{/* Additional Help Card */}
|
| 156 |
+
<div className="mt-6 bg-white/[0.02] border border-white/10 rounded-xl p-6">
|
| 157 |
+
<h3 className="text-sm font-semibold text-white mb-3">Quick Troubleshooting</h3>
|
| 158 |
+
<ul className="space-y-2 text-sm text-gray-400">
|
| 159 |
+
<li className="flex items-start gap-2">
|
| 160 |
+
<span className="text-purple-400 mt-0.5">•</span>
|
| 161 |
+
<span>Try clearing your browser cache and cookies</span>
|
| 162 |
+
</li>
|
| 163 |
+
<li className="flex items-start gap-2">
|
| 164 |
+
<span className="text-purple-400 mt-0.5">•</span>
|
| 165 |
+
<span>Check your internet connection</span>
|
| 166 |
+
</li>
|
| 167 |
+
<li className="flex items-start gap-2">
|
| 168 |
+
<span className="text-purple-400 mt-0.5">•</span>
|
| 169 |
+
<span>Make sure you're using a supported browser</span>
|
| 170 |
+
</li>
|
| 171 |
+
<li className="flex items-start gap-2">
|
| 172 |
+
<span className="text-purple-400 mt-0.5">•</span>
|
| 173 |
+
<span>Disable browser extensions that might interfere</span>
|
| 174 |
+
</li>
|
| 175 |
+
</ul>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return this.props.children;
|
| 183 |
+
}
|
| 184 |
+
}
|
src/components/Footer.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Terminal } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
export function Footer() {
|
| 4 |
+
return (
|
| 5 |
+
<footer className="bg-background border-t border-border/40 py-12">
|
| 6 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 7 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
| 8 |
+
<div className="col-span-1 md:col-span-2">
|
| 9 |
+
<div className="flex items-center gap-2 mb-4">
|
| 10 |
+
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
| 11 |
+
<Terminal className="w-5 h-5 text-primary" />
|
| 12 |
+
</div>
|
| 13 |
+
<span className="font-display font-bold text-xl tracking-tight text-white">
|
| 14 |
+
Ditzzy<span className="text-primary">API</span>
|
| 15 |
+
</span>
|
| 16 |
+
</div>
|
| 17 |
+
<p className="text-muted-foreground max-w-sm">
|
| 18 |
+
The developer-first API platform. Secure, scalable, and effortless integration for modern applications.
|
| 19 |
+
</p>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div>
|
| 23 |
+
<h4 className="font-semibold text-white mb-4">Links</h4>
|
| 24 |
+
<ul className="space-y-2 text-sm text-muted-foreground">
|
| 25 |
+
<li><a href="/docs" className="hover:text-primary transition-colors">Documentation</a></li>
|
| 26 |
+
<li><a href="#" className="hover:text-primary transition-colors">Status</a></li>
|
| 27 |
+
</ul>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div>
|
| 31 |
+
<h4 className="font-semibold text-white mb-4">Legal</h4>
|
| 32 |
+
<ul className="space-y-2 text-sm text-muted-foreground">
|
| 33 |
+
<li><a href="#" className="hover:text-primary transition-colors">Privacy Policy</a></li>
|
| 34 |
+
<li><a href="#" className="hover:text-primary transition-colors">Terms of Service</a></li>
|
| 35 |
+
</ul>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div className="mt-12 pt-8 border-t border-border/40 text-center text-sm text-muted-foreground">
|
| 40 |
+
© {new Date().getFullYear()} DitzzyAPI. All rights reserved.
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</footer>
|
| 44 |
+
);
|
| 45 |
+
}
|
src/components/Navbar.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { Link } from "react-router-dom";
|
| 3 |
+
import { Menu, Terminal, X, Home, BookOpen, ChevronRight } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { useCategories } from "@/client/hooks/usePlugin";
|
| 6 |
+
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
| 7 |
+
|
| 8 |
+
interface NavbarProps {
|
| 9 |
+
onCategorySelect?: (category: string | null) => void;
|
| 10 |
+
selectedCategory?: string | null;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function Navbar({ onCategorySelect, selectedCategory }: NavbarProps) {
|
| 14 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 15 |
+
const { categories } = useCategories();
|
| 16 |
+
|
| 17 |
+
const handleCategoryClick = (category: string) => {
|
| 18 |
+
onCategorySelect?.(category);
|
| 19 |
+
setIsOpen(false);
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<nav className="border-b border-white/10 bg-black/50 backdrop-blur-xl sticky top-0 z-50">
|
| 24 |
+
<div className="max-w-7xl mx-auto px-4 py-4">
|
| 25 |
+
<div className="flex items-center justify-between">
|
| 26 |
+
{/* Logo */}
|
| 27 |
+
<Link to="/" className="text-xl font-bold text-white flex items-center gap-2">
|
| 28 |
+
<Terminal className="w-5 h-5 text-primary" />
|
| 29 |
+
DitzzyAPI
|
| 30 |
+
</Link>
|
| 31 |
+
|
| 32 |
+
{/* Desktop Navigation */}
|
| 33 |
+
<div className="hidden md:flex items-center gap-6">
|
| 34 |
+
<Link
|
| 35 |
+
to="/"
|
| 36 |
+
className="text-gray-400 hover:text-white transition flex items-center gap-2"
|
| 37 |
+
>
|
| 38 |
+
<Home className="w-4 h-4" />
|
| 39 |
+
Home
|
| 40 |
+
</Link>
|
| 41 |
+
<Link to="/docs" className="text-purple-400 font-medium flex items-center gap-2">
|
| 42 |
+
<BookOpen className="w-4 h-4" />
|
| 43 |
+
Documentation
|
| 44 |
+
</Link>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{/* Hamburger Menu - Categories */}
|
| 48 |
+
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
| 49 |
+
<SheetTrigger asChild>
|
| 50 |
+
<Button variant="ghost" size="icon" className="text-white">
|
| 51 |
+
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
| 52 |
+
</Button>
|
| 53 |
+
</SheetTrigger>
|
| 54 |
+
<SheetContent side="right" className="w-80 bg-background border-white/10">
|
| 55 |
+
<SheetHeader>
|
| 56 |
+
<SheetTitle className="text-white">Categories</SheetTitle>
|
| 57 |
+
</SheetHeader>
|
| 58 |
+
|
| 59 |
+
<div className="mt-6 space-y-2">
|
| 60 |
+
{/* All Endpoints */}
|
| 61 |
+
<button
|
| 62 |
+
onClick={() => handleCategoryClick("")}
|
| 63 |
+
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-left transition ${
|
| 64 |
+
!selectedCategory
|
| 65 |
+
? "bg-purple-500/20 text-purple-400 border border-purple-500/50"
|
| 66 |
+
: "bg-white/5 text-gray-300 hover:bg-white/10"
|
| 67 |
+
}`}
|
| 68 |
+
>
|
| 69 |
+
<span className="font-medium">All Endpoints</span>
|
| 70 |
+
<ChevronRight className="w-4 h-4" />
|
| 71 |
+
</button>
|
| 72 |
+
|
| 73 |
+
{/* Categories */}
|
| 74 |
+
{categories.map((cat) => (
|
| 75 |
+
<button
|
| 76 |
+
key={cat.name}
|
| 77 |
+
onClick={() => handleCategoryClick(cat.name)}
|
| 78 |
+
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-left transition ${
|
| 79 |
+
selectedCategory === cat.name
|
| 80 |
+
? "bg-purple-500/20 text-purple-400 border border-purple-500/50"
|
| 81 |
+
: "bg-white/5 text-gray-300 hover:bg-white/10"
|
| 82 |
+
}`}
|
| 83 |
+
>
|
| 84 |
+
<span className="capitalize">{cat.name}</span>
|
| 85 |
+
<div className="flex items-center gap-2">
|
| 86 |
+
<span className="text-xs bg-white/10 px-2 py-1 rounded">{cat.count}</span>
|
| 87 |
+
<ChevronRight className="w-4 h-4" />
|
| 88 |
+
</div>
|
| 89 |
+
</button>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{/* Mobile Navigation Links */}
|
| 94 |
+
<div className="md:hidden mt-8 space-y-2 pt-6 border-t border-white/10">
|
| 95 |
+
<Link
|
| 96 |
+
to="/"
|
| 97 |
+
onClick={() => setIsOpen(false)}
|
| 98 |
+
className="flex items-center gap-2 px-4 py-3 rounded-lg text-gray-300 hover:bg-white/10 transition"
|
| 99 |
+
>
|
| 100 |
+
<Home className="w-4 h-4" />
|
| 101 |
+
Home
|
| 102 |
+
</Link>
|
| 103 |
+
<Link
|
| 104 |
+
to="/docs"
|
| 105 |
+
onClick={() => setIsOpen(false)}
|
| 106 |
+
className="flex items-center gap-2 px-4 py-3 rounded-lg text-purple-400 hover:bg-white/10 transition"
|
| 107 |
+
>
|
| 108 |
+
<BookOpen className="w-4 h-4" />
|
| 109 |
+
Documentation
|
| 110 |
+
</Link>
|
| 111 |
+
</div>
|
| 112 |
+
</SheetContent>
|
| 113 |
+
</Sheet>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
</nav>
|
| 117 |
+
);
|
| 118 |
+
}
|
src/components/PluginCard.tsx
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { Card } from "@/components/ui/card";
|
| 3 |
+
import { Badge } from "@/components/ui/badge";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Input } from "@/components/ui/input";
|
| 6 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 7 |
+
import { PluginMetadata } from "@/client/hooks/usePlugin";
|
| 8 |
+
import { Play, ChevronDown, ChevronUp, Copy, Check } from "lucide-react";
|
| 9 |
+
import { CodeBlock } from "@/components/CodeBlock";
|
| 10 |
+
import { getApiUrl } from "@/lib/api-url";
|
| 11 |
+
|
| 12 |
+
interface PluginCardProps {
|
| 13 |
+
plugin: PluginMetadata;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const methodColors: Record<string, string> = {
|
| 17 |
+
GET: "bg-green-500/20 text-green-400 border-green-500/50",
|
| 18 |
+
POST: "bg-blue-500/20 text-blue-400 border-blue-500/50",
|
| 19 |
+
PUT: "bg-yellow-500/20 text-yellow-400 border-yellow-500/50",
|
| 20 |
+
DELETE: "bg-red-500/20 text-red-400 border-red-500/50",
|
| 21 |
+
PATCH: "bg-purple-500/20 text-purple-400 border-purple-500/50",
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export function PluginCard({ plugin }: PluginCardProps) {
|
| 25 |
+
const [paramValues, setParamValues] = useState<Record<string, string>>({});
|
| 26 |
+
const [response, setResponse] = useState<any>(null);
|
| 27 |
+
const [responseHeaders, setResponseHeaders] = useState<Record<string, string>>({});
|
| 28 |
+
const [requestUrl, setRequestUrl] = useState<string>("");
|
| 29 |
+
const [loading, setLoading] = useState(false);
|
| 30 |
+
const [isExpanded, setIsExpanded] = useState(false);
|
| 31 |
+
const [copiedUrl, setCopiedUrl] = useState(false);
|
| 32 |
+
const [copiedRequestUrl, setCopiedRequestUrl] = useState(false);
|
| 33 |
+
|
| 34 |
+
const handleParamChange = (paramName: string, value: string) => {
|
| 35 |
+
setParamValues((prev) => ({ ...prev, [paramName]: value }));
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const handleExecute = async () => {
|
| 39 |
+
setLoading(true);
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
let url = "/api" + plugin.endpoint;
|
| 43 |
+
let fullUrl = getApiUrl(plugin.endpoint);
|
| 44 |
+
|
| 45 |
+
if (plugin.method === "GET" && plugin.parameters?.query) {
|
| 46 |
+
const queryParams = new URLSearchParams();
|
| 47 |
+
plugin.parameters.query.forEach((param) => {
|
| 48 |
+
const value = paramValues[param.name];
|
| 49 |
+
if (value) {
|
| 50 |
+
queryParams.append(param.name, value);
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
if (queryParams.toString()) {
|
| 55 |
+
url += "?" + queryParams.toString();
|
| 56 |
+
fullUrl += "?" + queryParams.toString();
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Store the request URL for display
|
| 61 |
+
setRequestUrl(fullUrl);
|
| 62 |
+
|
| 63 |
+
const fetchOptions: RequestInit = {
|
| 64 |
+
method: plugin.method,
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
// Add body for POST/PUT/PATCH
|
| 68 |
+
if (["POST", "PUT", "PATCH"].includes(plugin.method) && plugin.parameters?.body) {
|
| 69 |
+
const bodyData: Record<string, any> = {};
|
| 70 |
+
plugin.parameters.body.forEach((param) => {
|
| 71 |
+
const value = paramValues[param.name];
|
| 72 |
+
if (value) {
|
| 73 |
+
bodyData[param.name] = value;
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
fetchOptions.body = JSON.stringify(bodyData);
|
| 77 |
+
fetchOptions.headers = {
|
| 78 |
+
"Content-Type": "application/json",
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const res = await fetch(url, fetchOptions);
|
| 83 |
+
const data = await res.json();
|
| 84 |
+
|
| 85 |
+
// Capture response headers
|
| 86 |
+
const headers: Record<string, string> = {};
|
| 87 |
+
res.headers.forEach((value, key) => {
|
| 88 |
+
headers[key] = value;
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
setResponseHeaders(headers);
|
| 92 |
+
setResponse({
|
| 93 |
+
status: res.status,
|
| 94 |
+
statusText: res.statusText,
|
| 95 |
+
data,
|
| 96 |
+
});
|
| 97 |
+
} catch (error) {
|
| 98 |
+
setResponse({
|
| 99 |
+
status: 500,
|
| 100 |
+
statusText: "Error",
|
| 101 |
+
data: { error: error instanceof Error ? error.message : "Unknown error" },
|
| 102 |
+
});
|
| 103 |
+
setResponseHeaders({});
|
| 104 |
+
} finally {
|
| 105 |
+
setLoading(false);
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const copyApiUrl = () => {
|
| 110 |
+
const fullUrl = getApiUrl(plugin.endpoint);
|
| 111 |
+
navigator.clipboard.writeText(fullUrl);
|
| 112 |
+
setCopiedUrl(true);
|
| 113 |
+
setTimeout(() => setCopiedUrl(false), 2000);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const copyRequestUrl = () => {
|
| 117 |
+
navigator.clipboard.writeText(requestUrl);
|
| 118 |
+
setCopiedRequestUrl(true);
|
| 119 |
+
setTimeout(() => setCopiedRequestUrl(false), 2000);
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const hasQueryParams = plugin.parameters?.query && plugin.parameters.query.length > 0;
|
| 123 |
+
const hasBodyParams = plugin.parameters?.body && plugin.parameters.body.length > 0;
|
| 124 |
+
const hasPathParams = plugin.parameters?.path && plugin.parameters.path.length > 0;
|
| 125 |
+
const hasAnyParams = hasQueryParams || hasBodyParams || hasPathParams;
|
| 126 |
+
|
| 127 |
+
const generateCurlExample = () => {
|
| 128 |
+
let curl = `curl -X ${plugin.method} "${getApiUrl(plugin.endpoint)}`;
|
| 129 |
+
|
| 130 |
+
if (hasQueryParams) {
|
| 131 |
+
const exampleParams = plugin.parameters!.query!
|
| 132 |
+
.map((p) => `${p.name}=${p.example || 'value'}`)
|
| 133 |
+
.join('&');
|
| 134 |
+
curl += `?${exampleParams}`;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
curl += '"';
|
| 138 |
+
|
| 139 |
+
if (hasBodyParams) {
|
| 140 |
+
curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'';
|
| 141 |
+
const bodyExample: Record<string, any> = {};
|
| 142 |
+
plugin.parameters!.body!.forEach((p) => {
|
| 143 |
+
bodyExample[p.name] = p.example || 'value';
|
| 144 |
+
});
|
| 145 |
+
curl += JSON.stringify(bodyExample, null, 2);
|
| 146 |
+
curl += "'";
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return curl;
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
const generateNodeExample = () => {
|
| 153 |
+
let code = `const response = await fetch("${getApiUrl(plugin.endpoint)}`;
|
| 154 |
+
|
| 155 |
+
if (hasQueryParams) {
|
| 156 |
+
const exampleParams = plugin.parameters!.query!
|
| 157 |
+
.map((p) => `${p.name}=${p.example || 'value'}`)
|
| 158 |
+
.join('&');
|
| 159 |
+
code += `?${exampleParams}`;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
code += '", {\n method: "' + plugin.method + '"';
|
| 163 |
+
|
| 164 |
+
if (hasBodyParams) {
|
| 165 |
+
code += ',\n headers: {\n "Content-Type": "application/json"\n },\n body: JSON.stringify(';
|
| 166 |
+
const bodyExample: Record<string, any> = {};
|
| 167 |
+
plugin.parameters!.body!.forEach((p) => {
|
| 168 |
+
bodyExample[p.name] = p.example || 'value';
|
| 169 |
+
});
|
| 170 |
+
code += JSON.stringify(bodyExample, null, 2);
|
| 171 |
+
code += ')';
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
code += '\n});\n\nconst data = await response.json();\nconsole.log(data);';
|
| 175 |
+
return code;
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<Card className="bg-white/[0.02] border-white/10 overflow-hidden">
|
| 180 |
+
{/* Collapsible Header */}
|
| 181 |
+
<div
|
| 182 |
+
className="p-6 border-b border-white/10 cursor-pointer hover:bg-white/[0.02] transition-colors"
|
| 183 |
+
onClick={() => setIsExpanded(!isExpanded)}
|
| 184 |
+
>
|
| 185 |
+
<div className="flex items-start justify-between gap-4">
|
| 186 |
+
<div className="flex-1 min-w-0">
|
| 187 |
+
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
| 188 |
+
<Badge className={`${methodColors[plugin.method]} border font-bold px-3 py-1 flex-shrink-0`}>
|
| 189 |
+
{plugin.method}
|
| 190 |
+
</Badge>
|
| 191 |
+
<code className="text-sm text-purple-400 font-mono break-all">{plugin.endpoint}</code>
|
| 192 |
+
<button
|
| 193 |
+
onClick={(e) => {
|
| 194 |
+
e.stopPropagation();
|
| 195 |
+
copyApiUrl();
|
| 196 |
+
}}
|
| 197 |
+
className="text-gray-400 hover:text-white transition-colors p-1 flex-shrink-0"
|
| 198 |
+
title="Copy API URL"
|
| 199 |
+
>
|
| 200 |
+
{copiedUrl ? (
|
| 201 |
+
<Check className="w-4 h-4 text-green-400" />
|
| 202 |
+
) : (
|
| 203 |
+
<Copy className="w-4 h-4" />
|
| 204 |
+
)}
|
| 205 |
+
</button>
|
| 206 |
+
</div>
|
| 207 |
+
<h3 className="text-xl font-bold text-white mb-2 break-words">{plugin.name}</h3>
|
| 208 |
+
<p className="text-gray-400 text-sm break-words">{plugin.description}</p>
|
| 209 |
+
|
| 210 |
+
{/* Tags */}
|
| 211 |
+
{plugin.tags && plugin.tags.length > 0 && (
|
| 212 |
+
<div className="flex flex-wrap gap-2 mt-3">
|
| 213 |
+
{plugin.tags.map((tag) => (
|
| 214 |
+
<Badge key={tag} variant="outline" className="bg-white/5 text-gray-400 border-white/10 text-xs">
|
| 215 |
+
{tag}
|
| 216 |
+
</Badge>
|
| 217 |
+
))}
|
| 218 |
+
</div>
|
| 219 |
+
)}
|
| 220 |
+
|
| 221 |
+
{/* API URL Display */}
|
| 222 |
+
<div className="mt-3 flex items-start gap-2">
|
| 223 |
+
<span className="text-xs text-gray-500 flex-shrink-0">API URL:</span>
|
| 224 |
+
<code className="text-xs text-gray-300 bg-black/30 px-2 py-1 rounded break-all">
|
| 225 |
+
{getApiUrl(plugin.endpoint)}
|
| 226 |
+
</code>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<button
|
| 231 |
+
className="text-gray-400 hover:text-white transition-colors flex-shrink-0 p-2 hover:bg-white/5 rounded-lg"
|
| 232 |
+
onClick={(e) => {
|
| 233 |
+
e.stopPropagation();
|
| 234 |
+
setIsExpanded(!isExpanded);
|
| 235 |
+
}}
|
| 236 |
+
>
|
| 237 |
+
{isExpanded ? (
|
| 238 |
+
<ChevronUp className="w-6 h-6" />
|
| 239 |
+
) : (
|
| 240 |
+
<ChevronDown className="w-6 h-6" />
|
| 241 |
+
)}
|
| 242 |
+
</button>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
{/* Expandable Content */}
|
| 247 |
+
{isExpanded && (
|
| 248 |
+
<Tabs defaultValue="try" className="w-full">
|
| 249 |
+
<TabsList className="w-full justify-start rounded-none border-b border-white/10 bg-transparent p-0">
|
| 250 |
+
<TabsTrigger
|
| 251 |
+
value="documentation"
|
| 252 |
+
className="rounded-none data-[state=active]:bg-transparent data-[state=active]:border-b-2 data-[state=active]:border-purple-500 data-[state=active]:text-purple-400 px-6 py-3"
|
| 253 |
+
>
|
| 254 |
+
Documentation
|
| 255 |
+
</TabsTrigger>
|
| 256 |
+
<TabsTrigger
|
| 257 |
+
value="try"
|
| 258 |
+
className="rounded-none data-[state=active]:bg-transparent data-[state=active]:border-b-2 data-[state=active]:border-purple-500 data-[state=active]:text-purple-400 px-6 py-3"
|
| 259 |
+
>
|
| 260 |
+
Try It Out
|
| 261 |
+
</TabsTrigger>
|
| 262 |
+
</TabsList>
|
| 263 |
+
|
| 264 |
+
{/* Documentation Tab */}
|
| 265 |
+
<TabsContent value="documentation" className="p-6 space-y-6">
|
| 266 |
+
{/* Parameters Table */}
|
| 267 |
+
{hasAnyParams && (
|
| 268 |
+
<div>
|
| 269 |
+
<h4 className="text-purple-400 font-semibold mb-3">Parameters</h4>
|
| 270 |
+
<div className="overflow-x-auto">
|
| 271 |
+
<table className="w-full text-sm">
|
| 272 |
+
<thead>
|
| 273 |
+
<tr className="border-b border-white/10">
|
| 274 |
+
<th className="text-left text-gray-400 font-medium pb-2 pr-4">Name</th>
|
| 275 |
+
<th className="text-left text-gray-400 font-medium pb-2 pr-4">Type</th>
|
| 276 |
+
<th className="text-left text-gray-400 font-medium pb-2 pr-4">Required</th>
|
| 277 |
+
<th className="text-left text-gray-400 font-medium pb-2">Description</th>
|
| 278 |
+
</tr>
|
| 279 |
+
</thead>
|
| 280 |
+
<tbody>
|
| 281 |
+
{/* Path Parameters */}
|
| 282 |
+
{plugin.parameters?.path?.map((param) => (
|
| 283 |
+
<tr key={param.name} className="border-b border-white/5">
|
| 284 |
+
<td className="py-3 pr-4 text-white font-mono">{param.name}</td>
|
| 285 |
+
<td className="py-3 pr-4 text-blue-400 font-mono text-xs">{param.type}</td>
|
| 286 |
+
<td className="py-3 pr-4">
|
| 287 |
+
<span className={param.required ? "text-red-400" : "text-gray-500"}>
|
| 288 |
+
{param.required ? "Yes" : "No"}
|
| 289 |
+
</span>
|
| 290 |
+
</td>
|
| 291 |
+
<td className="py-3 text-gray-400">{param.description}</td>
|
| 292 |
+
</tr>
|
| 293 |
+
))}
|
| 294 |
+
{/* Query Parameters */}
|
| 295 |
+
{plugin.parameters?.query?.map((param) => (
|
| 296 |
+
<tr key={param.name} className="border-b border-white/5">
|
| 297 |
+
<td className="py-3 pr-4 text-white font-mono">{param.name}</td>
|
| 298 |
+
<td className="py-3 pr-4 text-blue-400 font-mono text-xs">{param.type}</td>
|
| 299 |
+
<td className="py-3 pr-4">
|
| 300 |
+
<span className={param.required ? "text-red-400" : "text-gray-500"}>
|
| 301 |
+
{param.required ? "Yes" : "No"}
|
| 302 |
+
</span>
|
| 303 |
+
</td>
|
| 304 |
+
<td className="py-3 text-gray-400">{param.description}</td>
|
| 305 |
+
</tr>
|
| 306 |
+
))}
|
| 307 |
+
{/* Body Parameters */}
|
| 308 |
+
{plugin.parameters?.body?.map((param) => (
|
| 309 |
+
<tr key={param.name} className="border-b border-white/5">
|
| 310 |
+
<td className="py-3 pr-4 text-white font-mono">{param.name}</td>
|
| 311 |
+
<td className="py-3 pr-4 text-blue-400 font-mono text-xs">{param.type}</td>
|
| 312 |
+
<td className="py-3 pr-4">
|
| 313 |
+
<span className={param.required ? "text-red-400" : "text-gray-500"}>
|
| 314 |
+
{param.required ? "Yes" : "No"}
|
| 315 |
+
</span>
|
| 316 |
+
</td>
|
| 317 |
+
<td className="py-3 text-gray-400">{param.description}</td>
|
| 318 |
+
</tr>
|
| 319 |
+
))}
|
| 320 |
+
</tbody>
|
| 321 |
+
</table>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
)}
|
| 325 |
+
|
| 326 |
+
{/* Responses */}
|
| 327 |
+
{plugin.responses && Object.keys(plugin.responses).length > 0 && (
|
| 328 |
+
<div>
|
| 329 |
+
<h4 className="text-purple-400 font-semibold mb-3">Responses</h4>
|
| 330 |
+
<div className="space-y-3">
|
| 331 |
+
{Object.entries(plugin.responses).map(([status, response]) => (
|
| 332 |
+
<div key={status} className="border border-white/10 rounded-lg overflow-hidden">
|
| 333 |
+
<div className={`px-4 py-2 flex items-center gap-3 ${
|
| 334 |
+
parseInt(status) >= 200 && parseInt(status) < 300
|
| 335 |
+
? "bg-green-500/10"
|
| 336 |
+
: parseInt(status) >= 400 && parseInt(status) < 500
|
| 337 |
+
? "bg-yellow-500/10"
|
| 338 |
+
: "bg-red-500/10"
|
| 339 |
+
}`}>
|
| 340 |
+
<Badge
|
| 341 |
+
className={`${
|
| 342 |
+
parseInt(status) >= 200 && parseInt(status) < 300
|
| 343 |
+
? "bg-green-500/20 text-green-400 border-green-500/50"
|
| 344 |
+
: parseInt(status) >= 400 && parseInt(status) < 500
|
| 345 |
+
? "bg-yellow-500/20 text-yellow-400 border-yellow-500/50"
|
| 346 |
+
: "bg-red-500/20 text-red-400 border-red-500/50"
|
| 347 |
+
} border font-bold`}
|
| 348 |
+
>
|
| 349 |
+
{status}
|
| 350 |
+
</Badge>
|
| 351 |
+
<span className="text-sm text-white">{response.description}</span>
|
| 352 |
+
</div>
|
| 353 |
+
<pre className="p-4 bg-black/50 text-xs overflow-x-auto">
|
| 354 |
+
<code className="text-gray-300">{JSON.stringify(response.example, null, 2)}</code>
|
| 355 |
+
</pre>
|
| 356 |
+
</div>
|
| 357 |
+
))}
|
| 358 |
+
</div>
|
| 359 |
+
</div>
|
| 360 |
+
)}
|
| 361 |
+
|
| 362 |
+
{/* Code Examples */}
|
| 363 |
+
<div>
|
| 364 |
+
<h4 className="text-purple-400 font-semibold mb-3">Code Example</h4>
|
| 365 |
+
<div className="space-y-3">
|
| 366 |
+
<div>
|
| 367 |
+
<div className="mb-2">
|
| 368 |
+
<span className="text-xs text-gray-400">cURL</span>
|
| 369 |
+
</div>
|
| 370 |
+
<CodeBlock code={generateCurlExample()} language="bash" />
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
<div>
|
| 374 |
+
<div className="mb-2">
|
| 375 |
+
<span className="text-xs text-gray-400">Node.js (fetch)</span>
|
| 376 |
+
</div>
|
| 377 |
+
<CodeBlock code={generateNodeExample()} language="javascript" />
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
</TabsContent>
|
| 382 |
+
|
| 383 |
+
{/* Try It Out Tab */}
|
| 384 |
+
<TabsContent value="try" className="p-6">
|
| 385 |
+
{/* Parameters Input */}
|
| 386 |
+
{hasAnyParams ? (
|
| 387 |
+
<div className="space-y-4 mb-4">
|
| 388 |
+
{/* Query Parameters */}
|
| 389 |
+
{plugin.parameters?.query?.map((param) => (
|
| 390 |
+
<div key={param.name}>
|
| 391 |
+
<label className="block text-sm text-gray-300 mb-2">
|
| 392 |
+
{param.name}
|
| 393 |
+
{param.required && <span className="text-red-400 ml-1">*</span>}
|
| 394 |
+
<span className="text-xs text-gray-500 ml-2">({param.type})</span>
|
| 395 |
+
</label>
|
| 396 |
+
<Input
|
| 397 |
+
type="text"
|
| 398 |
+
placeholder={param.example?.toString() || param.description}
|
| 399 |
+
value={paramValues[param.name] || ""}
|
| 400 |
+
onChange={(e) => handleParamChange(param.name, e.target.value)}
|
| 401 |
+
className="bg-black/50 border-white/10 text-white focus:border-purple-500"
|
| 402 |
+
/>
|
| 403 |
+
<p className="text-xs text-gray-500 mt-1">{param.description}</p>
|
| 404 |
+
</div>
|
| 405 |
+
))}
|
| 406 |
+
|
| 407 |
+
{/* Body Parameters */}
|
| 408 |
+
{plugin.parameters?.body?.map((param) => (
|
| 409 |
+
<div key={param.name}>
|
| 410 |
+
<label className="block text-sm text-gray-300 mb-2">
|
| 411 |
+
{param.name}
|
| 412 |
+
{param.required && <span className="text-red-400 ml-1">*</span>}
|
| 413 |
+
<span className="text-xs text-gray-500 ml-2">({param.type})</span>
|
| 414 |
+
</label>
|
| 415 |
+
<Input
|
| 416 |
+
type="text"
|
| 417 |
+
placeholder={param.example?.toString() || param.description}
|
| 418 |
+
value={paramValues[param.name] || ""}
|
| 419 |
+
onChange={(e) => handleParamChange(param.name, e.target.value)}
|
| 420 |
+
className="bg-black/50 border-white/10 text-white focus:border-purple-500"
|
| 421 |
+
/>
|
| 422 |
+
<p className="text-xs text-gray-500 mt-1">{param.description}</p>
|
| 423 |
+
</div>
|
| 424 |
+
))}
|
| 425 |
+
</div>
|
| 426 |
+
) : (
|
| 427 |
+
<p className="text-sm text-gray-400 mb-4">No parameters required</p>
|
| 428 |
+
)}
|
| 429 |
+
|
| 430 |
+
{/* Execute Button */}
|
| 431 |
+
<Button
|
| 432 |
+
onClick={handleExecute}
|
| 433 |
+
disabled={loading}
|
| 434 |
+
className="w-full bg-purple-500 hover:bg-purple-600 text-white py-6 text-base font-semibold"
|
| 435 |
+
>
|
| 436 |
+
<Play className="w-5 h-5 mr-2" />
|
| 437 |
+
{loading ? "Executing..." : "Execute"}
|
| 438 |
+
</Button>
|
| 439 |
+
|
| 440 |
+
{/* Response Display */}
|
| 441 |
+
{response && (
|
| 442 |
+
<div className="mt-6 space-y-4">
|
| 443 |
+
{/* Request URL */}
|
| 444 |
+
<div>
|
| 445 |
+
<div className="flex items-center justify-between mb-2">
|
| 446 |
+
<span className="text-sm text-gray-400">Request URL</span>
|
| 447 |
+
<button
|
| 448 |
+
onClick={copyRequestUrl}
|
| 449 |
+
className="text-gray-400 hover:text-white transition-colors p-1"
|
| 450 |
+
title="Copy Request URL"
|
| 451 |
+
>
|
| 452 |
+
{copiedRequestUrl ? (
|
| 453 |
+
<Check className="w-4 h-4 text-green-400" />
|
| 454 |
+
) : (
|
| 455 |
+
<Copy className="w-4 h-4" />
|
| 456 |
+
)}
|
| 457 |
+
</button>
|
| 458 |
+
</div>
|
| 459 |
+
<div className="bg-black/50 border border-white/10 rounded p-3 overflow-x-auto">
|
| 460 |
+
<code className="text-xs text-purple-300 break-all">{requestUrl}</code>
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
{/* Response Status */}
|
| 465 |
+
<div className="flex items-center justify-between">
|
| 466 |
+
<span className="text-sm text-gray-400">Response Status</span>
|
| 467 |
+
<Badge className={`${
|
| 468 |
+
response.status >= 200 && response.status < 300
|
| 469 |
+
? "bg-green-500/20 text-green-400"
|
| 470 |
+
: "bg-red-500/20 text-red-400"
|
| 471 |
+
}`}>
|
| 472 |
+
{response.status} {response.statusText}
|
| 473 |
+
</Badge>
|
| 474 |
+
</div>
|
| 475 |
+
|
| 476 |
+
{/* Response Headers */}
|
| 477 |
+
{Object.keys(responseHeaders).length > 0 && (
|
| 478 |
+
<div>
|
| 479 |
+
<h5 className="text-sm text-gray-400 mb-2">Response Headers</h5>
|
| 480 |
+
<div className="bg-black/50 border border-white/10 rounded p-4 space-y-1 overflow-x-auto">
|
| 481 |
+
{Object.entries(responseHeaders).map(([key, value]) => (
|
| 482 |
+
<div key={key} className="text-xs">
|
| 483 |
+
<span className="text-purple-400">{key}:</span>{" "}
|
| 484 |
+
<span className="text-gray-300">{value}</span>
|
| 485 |
+
</div>
|
| 486 |
+
))}
|
| 487 |
+
</div>
|
| 488 |
+
</div>
|
| 489 |
+
)}
|
| 490 |
+
|
| 491 |
+
{/* Response Body with Syntax Highlighting */}
|
| 492 |
+
<div>
|
| 493 |
+
<h5 className="text-sm text-gray-400 mb-2">Response Body</h5>
|
| 494 |
+
<CodeBlock
|
| 495 |
+
code={JSON.stringify(response.data, null, 2)}
|
| 496 |
+
language="json"
|
| 497 |
+
/>
|
| 498 |
+
</div>
|
| 499 |
+
</div>
|
| 500 |
+
)}
|
| 501 |
+
</TabsContent>
|
| 502 |
+
</Tabs>
|
| 503 |
+
)}
|
| 504 |
+
</Card>
|
| 505 |
+
);
|
| 506 |
+
}
|
src/components/StatsCard.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card } from "@/components/ui/card";
|
| 2 |
+
import { LucideIcon } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
interface StatsCardProps {
|
| 5 |
+
title: string;
|
| 6 |
+
value: string | number;
|
| 7 |
+
icon: LucideIcon;
|
| 8 |
+
color: "purple" | "green" | "red" | "blue";
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const colorClasses = {
|
| 12 |
+
purple: "text-purple-400 bg-purple-500/10 border-purple-500/20",
|
| 13 |
+
green: "text-green-400 bg-green-500/10 border-green-500/20",
|
| 14 |
+
red: "text-red-400 bg-red-500/10 border-red-500/20",
|
| 15 |
+
blue: "text-blue-400 bg-blue-500/10 border-blue-500/20",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export function StatsCard({ title, value, icon: Icon, color }: StatsCardProps) {
|
| 19 |
+
return (
|
| 20 |
+
<Card className={`p-4 backdrop-blur-sm ${colorClasses[color]}`}>
|
| 21 |
+
<div className="flex items-center justify-between mb-2">
|
| 22 |
+
<Icon className="w-5 h-5" />
|
| 23 |
+
</div>
|
| 24 |
+
<div className={`text-2xl font-bold ${colorClasses[color].split(" ")[0]}`}>
|
| 25 |
+
{value}
|
| 26 |
+
</div>
|
| 27 |
+
<div className="text-xs text-muted-foreground mt-1">{title}</div>
|
| 28 |
+
</Card>
|
| 29 |
+
);
|
| 30 |
+
}
|
src/components/VisitorChart.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import { Card } from "@/components/ui/card";
|
| 3 |
+
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
| 4 |
+
import { Users, Loader2 } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
interface VisitorData {
|
| 7 |
+
timestamp: number;
|
| 8 |
+
count: number;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function VisitorChart() {
|
| 12 |
+
const [data, setData] = useState<VisitorData[]>([]);
|
| 13 |
+
const [loading, setLoading] = useState(true);
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
fetchVisitorData();
|
| 17 |
+
const interval = setInterval(fetchVisitorData, 5 * 60 * 1000);
|
| 18 |
+
return () => clearInterval(interval);
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const fetchVisitorData = async () => {
|
| 22 |
+
try {
|
| 23 |
+
const res = await fetch("/api/stats/visitors");
|
| 24 |
+
const json = await res.json();
|
| 25 |
+
if (json.success) {
|
| 26 |
+
setData(json.data);
|
| 27 |
+
}
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.error("Failed to fetch visitor data:", error);
|
| 30 |
+
} finally {
|
| 31 |
+
setLoading(false);
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const formatXAxis = (timestamp: number) => {
|
| 36 |
+
const date = new Date(timestamp);
|
| 37 |
+
return date.getHours().toString().padStart(2, '0') + ':00';
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const CustomTooltip = ({ active, payload }: any) => {
|
| 41 |
+
if (active && payload && payload.length) {
|
| 42 |
+
const data = payload[0].payload;
|
| 43 |
+
const date = new Date(data.timestamp);
|
| 44 |
+
const timeStr = date.toLocaleString('en-US', {
|
| 45 |
+
month: 'short',
|
| 46 |
+
day: 'numeric',
|
| 47 |
+
hour: '2-digit',
|
| 48 |
+
minute: '2-digit',
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="bg-black/90 border border-white/20 rounded-lg p-3 backdrop-blur-sm">
|
| 53 |
+
<p className="text-xs text-gray-400 mb-1">{timeStr}</p>
|
| 54 |
+
<p className="text-sm font-semibold text-purple-400">
|
| 55 |
+
{data.count} visitor{data.count !== 1 ? 's' : ''}
|
| 56 |
+
</p>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
return null;
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<Card className="p-6 bg-white/[0.02] border-white/10">
|
| 65 |
+
<div className="flex items-center gap-2 mb-4">
|
| 66 |
+
<Users className="w-5 h-5 text-purple-400" />
|
| 67 |
+
<h3 className="text-lg font-semibold text-white">Visitor Activity (Last 24 Hours)</h3>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
{loading ? (
|
| 71 |
+
<div className="h-64 flex items-center justify-center">
|
| 72 |
+
<Loader2 className="w-6 h-6 text-purple-400 animate-spin" />
|
| 73 |
+
</div>
|
| 74 |
+
) : (
|
| 75 |
+
<ResponsiveContainer width="100%" height={300}>
|
| 76 |
+
<LineChart data={data} margin={{ top: 5, right: 30, left: 0, bottom: 5 }}>
|
| 77 |
+
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
| 78 |
+
<XAxis
|
| 79 |
+
dataKey="timestamp"
|
| 80 |
+
tickFormatter={formatXAxis}
|
| 81 |
+
stroke="rgba(255,255,255,0.5)"
|
| 82 |
+
style={{ fontSize: '12px' }}
|
| 83 |
+
interval="preserveStartEnd"
|
| 84 |
+
/>
|
| 85 |
+
<YAxis
|
| 86 |
+
stroke="rgba(255,255,255,0.5)"
|
| 87 |
+
style={{ fontSize: '12px' }}
|
| 88 |
+
allowDecimals={false}
|
| 89 |
+
/>
|
| 90 |
+
<Tooltip content={<CustomTooltip />} />
|
| 91 |
+
<Line
|
| 92 |
+
type="monotone"
|
| 93 |
+
dataKey="count"
|
| 94 |
+
stroke="#a855f7"
|
| 95 |
+
strokeWidth={2}
|
| 96 |
+
dot={{ fill: '#a855f7', r: 3 }}
|
| 97 |
+
activeDot={{ r: 5 }}
|
| 98 |
+
/>
|
| 99 |
+
</LineChart>
|
| 100 |
+
</ResponsiveContainer>
|
| 101 |
+
)}
|
| 102 |
+
</Card>
|
| 103 |
+
);
|
| 104 |
+
}
|
src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
const badgeVariants = cva(
|
| 7 |
+
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default:
|
| 12 |
+
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
| 13 |
+
secondary:
|
| 14 |
+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 15 |
+
destructive:
|
| 16 |
+
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
| 17 |
+
outline: "text-foreground",
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
defaultVariants: {
|
| 21 |
+
variant: "default",
|
| 22 |
+
},
|
| 23 |
+
}
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
export interface BadgeProps
|
| 27 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
| 28 |
+
VariantProps<typeof badgeVariants> {}
|
| 29 |
+
|
| 30 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
| 31 |
+
return (
|
| 32 |
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
| 33 |
+
)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export { Badge, badgeVariants }
|
src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default:
|
| 13 |
+
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
| 14 |
+
destructive:
|
| 15 |
+
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
| 16 |
+
outline:
|
| 17 |
+
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
| 18 |
+
secondary:
|
| 19 |
+
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
| 20 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
| 21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
+
},
|
| 23 |
+
size: {
|
| 24 |
+
default: "h-9 px-4 py-2",
|
| 25 |
+
sm: "h-8 rounded-md px-3 text-xs",
|
| 26 |
+
lg: "h-10 rounded-md px-8",
|
| 27 |
+
icon: "h-9 w-9",
|
| 28 |
+
},
|
| 29 |
+
},
|
| 30 |
+
defaultVariants: {
|
| 31 |
+
variant: "default",
|
| 32 |
+
size: "default",
|
| 33 |
+
},
|
| 34 |
+
}
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
export interface ButtonProps
|
| 38 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 39 |
+
VariantProps<typeof buttonVariants> {
|
| 40 |
+
asChild?: boolean
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 44 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 45 |
+
const Comp = asChild ? Slot : "button"
|
| 46 |
+
return (
|
| 47 |
+
<Comp
|
| 48 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 49 |
+
ref={ref}
|
| 50 |
+
{...props}
|
| 51 |
+
/>
|
| 52 |
+
)
|
| 53 |
+
}
|
| 54 |
+
)
|
| 55 |
+
Button.displayName = "Button"
|
| 56 |
+
|
| 57 |
+
export { Button, buttonVariants }
|
src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
const Card = React.forwardRef<
|
| 6 |
+
HTMLDivElement,
|
| 7 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 8 |
+
>(({ className, ...props }, ref) => (
|
| 9 |
+
<div
|
| 10 |
+
ref={ref}
|
| 11 |
+
className={cn(
|
| 12 |
+
"rounded-xl border bg-card text-card-foreground shadow",
|
| 13 |
+
className
|
| 14 |
+
)}
|
| 15 |
+
{...props}
|
| 16 |
+
/>
|
| 17 |
+
))
|
| 18 |
+
Card.displayName = "Card"
|
| 19 |
+
|
| 20 |
+
const CardHeader = React.forwardRef<
|
| 21 |
+
HTMLDivElement,
|
| 22 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 23 |
+
>(({ className, ...props }, ref) => (
|
| 24 |
+
<div
|
| 25 |
+
ref={ref}
|
| 26 |
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
| 27 |
+
{...props}
|
| 28 |
+
/>
|
| 29 |
+
))
|
| 30 |
+
CardHeader.displayName = "CardHeader"
|
| 31 |
+
|
| 32 |
+
const CardTitle = React.forwardRef<
|
| 33 |
+
HTMLDivElement,
|
| 34 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 35 |
+
>(({ className, ...props }, ref) => (
|
| 36 |
+
<div
|
| 37 |
+
ref={ref}
|
| 38 |
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
| 39 |
+
{...props}
|
| 40 |
+
/>
|
| 41 |
+
))
|
| 42 |
+
CardTitle.displayName = "CardTitle"
|
| 43 |
+
|
| 44 |
+
const CardDescription = React.forwardRef<
|
| 45 |
+
HTMLDivElement,
|
| 46 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 47 |
+
>(({ className, ...props }, ref) => (
|
| 48 |
+
<div
|
| 49 |
+
ref={ref}
|
| 50 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 51 |
+
{...props}
|
| 52 |
+
/>
|
| 53 |
+
))
|
| 54 |
+
CardDescription.displayName = "CardDescription"
|
| 55 |
+
|
| 56 |
+
const CardContent = React.forwardRef<
|
| 57 |
+
HTMLDivElement,
|
| 58 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 59 |
+
>(({ className, ...props }, ref) => (
|
| 60 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 61 |
+
))
|
| 62 |
+
CardContent.displayName = "CardContent"
|
| 63 |
+
|
| 64 |
+
const CardFooter = React.forwardRef<
|
| 65 |
+
HTMLDivElement,
|
| 66 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 67 |
+
>(({ className, ...props }, ref) => (
|
| 68 |
+
<div
|
| 69 |
+
ref={ref}
|
| 70 |
+
className={cn("flex items-center p-6 pt-0", className)}
|
| 71 |
+
{...props}
|
| 72 |
+
/>
|
| 73 |
+
))
|
| 74 |
+
CardFooter.displayName = "CardFooter"
|
| 75 |
+
|
| 76 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
| 6 |
+
({ className, type, ...props }, ref) => {
|
| 7 |
+
return (
|
| 8 |
+
<input
|
| 9 |
+
type={type}
|
| 10 |
+
className={cn(
|
| 11 |
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
| 12 |
+
className
|
| 13 |
+
)}
|
| 14 |
+
ref={ref}
|
| 15 |
+
{...props}
|
| 16 |
+
/>
|
| 17 |
+
)
|
| 18 |
+
}
|
| 19 |
+
)
|
| 20 |
+
Input.displayName = "Input"
|
| 21 |
+
|
| 22 |
+
export { Input }
|
src/components/ui/select.tsx
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as SelectPrimitive from "@radix-ui/react-select"
|
| 5 |
+
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
const Select = SelectPrimitive.Root
|
| 10 |
+
|
| 11 |
+
const SelectGroup = SelectPrimitive.Group
|
| 12 |
+
|
| 13 |
+
const SelectValue = SelectPrimitive.Value
|
| 14 |
+
|
| 15 |
+
const SelectTrigger = React.forwardRef<
|
| 16 |
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
| 17 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
| 18 |
+
>(({ className, children, ...props }, ref) => (
|
| 19 |
+
<SelectPrimitive.Trigger
|
| 20 |
+
ref={ref}
|
| 21 |
+
className={cn(
|
| 22 |
+
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
| 23 |
+
className
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
>
|
| 27 |
+
{children}
|
| 28 |
+
<SelectPrimitive.Icon asChild>
|
| 29 |
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
| 30 |
+
</SelectPrimitive.Icon>
|
| 31 |
+
</SelectPrimitive.Trigger>
|
| 32 |
+
))
|
| 33 |
+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
| 34 |
+
|
| 35 |
+
const SelectScrollUpButton = React.forwardRef<
|
| 36 |
+
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
| 37 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
| 38 |
+
>(({ className, ...props }, ref) => (
|
| 39 |
+
<SelectPrimitive.ScrollUpButton
|
| 40 |
+
ref={ref}
|
| 41 |
+
className={cn(
|
| 42 |
+
"flex cursor-default items-center justify-center py-1",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
<ChevronUp className="h-4 w-4" />
|
| 48 |
+
</SelectPrimitive.ScrollUpButton>
|
| 49 |
+
))
|
| 50 |
+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
| 51 |
+
|
| 52 |
+
const SelectScrollDownButton = React.forwardRef<
|
| 53 |
+
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
| 54 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
| 55 |
+
>(({ className, ...props }, ref) => (
|
| 56 |
+
<SelectPrimitive.ScrollDownButton
|
| 57 |
+
ref={ref}
|
| 58 |
+
className={cn(
|
| 59 |
+
"flex cursor-default items-center justify-center py-1",
|
| 60 |
+
className
|
| 61 |
+
)}
|
| 62 |
+
{...props}
|
| 63 |
+
>
|
| 64 |
+
<ChevronDown className="h-4 w-4" />
|
| 65 |
+
</SelectPrimitive.ScrollDownButton>
|
| 66 |
+
))
|
| 67 |
+
SelectScrollDownButton.displayName =
|
| 68 |
+
SelectPrimitive.ScrollDownButton.displayName
|
| 69 |
+
|
| 70 |
+
const SelectContent = React.forwardRef<
|
| 71 |
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
| 72 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
| 73 |
+
>(({ className, children, position = "popper", ...props }, ref) => (
|
| 74 |
+
<SelectPrimitive.Portal>
|
| 75 |
+
<SelectPrimitive.Content
|
| 76 |
+
ref={ref}
|
| 77 |
+
className={cn(
|
| 78 |
+
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out 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 origin-[--radix-select-content-transform-origin]",
|
| 79 |
+
position === "popper" &&
|
| 80 |
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
| 81 |
+
className
|
| 82 |
+
)}
|
| 83 |
+
position={position}
|
| 84 |
+
{...props}
|
| 85 |
+
>
|
| 86 |
+
<SelectScrollUpButton />
|
| 87 |
+
<SelectPrimitive.Viewport
|
| 88 |
+
className={cn(
|
| 89 |
+
"p-1",
|
| 90 |
+
position === "popper" &&
|
| 91 |
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
| 92 |
+
)}
|
| 93 |
+
>
|
| 94 |
+
{children}
|
| 95 |
+
</SelectPrimitive.Viewport>
|
| 96 |
+
<SelectScrollDownButton />
|
| 97 |
+
</SelectPrimitive.Content>
|
| 98 |
+
</SelectPrimitive.Portal>
|
| 99 |
+
))
|
| 100 |
+
SelectContent.displayName = SelectPrimitive.Content.displayName
|
| 101 |
+
|
| 102 |
+
const SelectLabel = React.forwardRef<
|
| 103 |
+
React.ElementRef<typeof SelectPrimitive.Label>,
|
| 104 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
| 105 |
+
>(({ className, ...props }, ref) => (
|
| 106 |
+
<SelectPrimitive.Label
|
| 107 |
+
ref={ref}
|
| 108 |
+
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
| 109 |
+
{...props}
|
| 110 |
+
/>
|
| 111 |
+
))
|
| 112 |
+
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
| 113 |
+
|
| 114 |
+
const SelectItem = React.forwardRef<
|
| 115 |
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
| 116 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
| 117 |
+
>(({ className, children, ...props }, ref) => (
|
| 118 |
+
<SelectPrimitive.Item
|
| 119 |
+
ref={ref}
|
| 120 |
+
className={cn(
|
| 121 |
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 122 |
+
className
|
| 123 |
+
)}
|
| 124 |
+
{...props}
|
| 125 |
+
>
|
| 126 |
+
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 127 |
+
<SelectPrimitive.ItemIndicator>
|
| 128 |
+
<Check className="h-4 w-4" />
|
| 129 |
+
</SelectPrimitive.ItemIndicator>
|
| 130 |
+
</span>
|
| 131 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 132 |
+
</SelectPrimitive.Item>
|
| 133 |
+
))
|
| 134 |
+
SelectItem.displayName = SelectPrimitive.Item.displayName
|
| 135 |
+
|
| 136 |
+
const SelectSeparator = React.forwardRef<
|
| 137 |
+
React.ElementRef<typeof SelectPrimitive.Separator>,
|
| 138 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
| 139 |
+
>(({ className, ...props }, ref) => (
|
| 140 |
+
<SelectPrimitive.Separator
|
| 141 |
+
ref={ref}
|
| 142 |
+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
| 143 |
+
{...props}
|
| 144 |
+
/>
|
| 145 |
+
))
|
| 146 |
+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
| 147 |
+
|
| 148 |
+
export {
|
| 149 |
+
Select,
|
| 150 |
+
SelectGroup,
|
| 151 |
+
SelectValue,
|
| 152 |
+
SelectTrigger,
|
| 153 |
+
SelectContent,
|
| 154 |
+
SelectLabel,
|
| 155 |
+
SelectItem,
|
| 156 |
+
SelectSeparator,
|
| 157 |
+
SelectScrollUpButton,
|
| 158 |
+
SelectScrollDownButton,
|
| 159 |
+
}
|
src/components/ui/separator.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
const Separator = React.forwardRef<
|
| 7 |
+
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
| 8 |
+
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
| 9 |
+
>(
|
| 10 |
+
(
|
| 11 |
+
{ className, orientation = "horizontal", decorative = true, ...props },
|
| 12 |
+
ref
|
| 13 |
+
) => (
|
| 14 |
+
<SeparatorPrimitive.Root
|
| 15 |
+
ref={ref}
|
| 16 |
+
decorative={decorative}
|
| 17 |
+
orientation={orientation}
|
| 18 |
+
className={cn(
|
| 19 |
+
"shrink-0 bg-border",
|
| 20 |
+
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
)
|
| 27 |
+
Separator.displayName = SeparatorPrimitive.Root.displayName
|
| 28 |
+
|
| 29 |
+
export { Separator }
|
src/components/ui/sheet.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
import { X } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
const Sheet = SheetPrimitive.Root
|
| 9 |
+
|
| 10 |
+
const SheetTrigger = SheetPrimitive.Trigger
|
| 11 |
+
|
| 12 |
+
const SheetClose = SheetPrimitive.Close
|
| 13 |
+
|
| 14 |
+
const SheetPortal = SheetPrimitive.Portal
|
| 15 |
+
|
| 16 |
+
const SheetOverlay = React.forwardRef<
|
| 17 |
+
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
| 18 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
| 19 |
+
>(({ className, ...props }, ref) => (
|
| 20 |
+
<SheetPrimitive.Overlay
|
| 21 |
+
className={cn(
|
| 22 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
| 23 |
+
className
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
ref={ref}
|
| 27 |
+
/>
|
| 28 |
+
))
|
| 29 |
+
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
| 30 |
+
|
| 31 |
+
const sheetVariants = cva(
|
| 32 |
+
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
| 33 |
+
{
|
| 34 |
+
variants: {
|
| 35 |
+
side: {
|
| 36 |
+
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
| 37 |
+
bottom:
|
| 38 |
+
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
| 39 |
+
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
| 40 |
+
right:
|
| 41 |
+
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
| 42 |
+
},
|
| 43 |
+
},
|
| 44 |
+
defaultVariants: {
|
| 45 |
+
side: "right",
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
interface SheetContentProps
|
| 51 |
+
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
| 52 |
+
VariantProps<typeof sheetVariants> {}
|
| 53 |
+
|
| 54 |
+
const SheetContent = React.forwardRef<
|
| 55 |
+
React.ElementRef<typeof SheetPrimitive.Content>,
|
| 56 |
+
SheetContentProps
|
| 57 |
+
>(({ side = "right", className, children, ...props }, ref) => (
|
| 58 |
+
<SheetPortal>
|
| 59 |
+
<SheetOverlay />
|
| 60 |
+
<SheetPrimitive.Content
|
| 61 |
+
ref={ref}
|
| 62 |
+
className={cn(sheetVariants({ side }), className)}
|
| 63 |
+
{...props}
|
| 64 |
+
>
|
| 65 |
+
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
| 66 |
+
<X className="h-4 w-4" />
|
| 67 |
+
<span className="sr-only">Close</span>
|
| 68 |
+
</SheetPrimitive.Close>
|
| 69 |
+
{children}
|
| 70 |
+
</SheetPrimitive.Content>
|
| 71 |
+
</SheetPortal>
|
| 72 |
+
))
|
| 73 |
+
SheetContent.displayName = SheetPrimitive.Content.displayName
|
| 74 |
+
|
| 75 |
+
const SheetHeader = ({
|
| 76 |
+
className,
|
| 77 |
+
...props
|
| 78 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 79 |
+
<div
|
| 80 |
+
className={cn(
|
| 81 |
+
"flex flex-col space-y-2 text-center sm:text-left",
|
| 82 |
+
className
|
| 83 |
+
)}
|
| 84 |
+
{...props}
|
| 85 |
+
/>
|
| 86 |
+
)
|
| 87 |
+
SheetHeader.displayName = "SheetHeader"
|
| 88 |
+
|
| 89 |
+
const SheetFooter = ({
|
| 90 |
+
className,
|
| 91 |
+
...props
|
| 92 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 93 |
+
<div
|
| 94 |
+
className={cn(
|
| 95 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
| 96 |
+
className
|
| 97 |
+
)}
|
| 98 |
+
{...props}
|
| 99 |
+
/>
|
| 100 |
+
)
|
| 101 |
+
SheetFooter.displayName = "SheetFooter"
|
| 102 |
+
|
| 103 |
+
const SheetTitle = React.forwardRef<
|
| 104 |
+
React.ElementRef<typeof SheetPrimitive.Title>,
|
| 105 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
| 106 |
+
>(({ className, ...props }, ref) => (
|
| 107 |
+
<SheetPrimitive.Title
|
| 108 |
+
ref={ref}
|
| 109 |
+
className={cn("text-lg font-semibold text-foreground", className)}
|
| 110 |
+
{...props}
|
| 111 |
+
/>
|
| 112 |
+
))
|
| 113 |
+
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
| 114 |
+
|
| 115 |
+
const SheetDescription = React.forwardRef<
|
| 116 |
+
React.ElementRef<typeof SheetPrimitive.Description>,
|
| 117 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
| 118 |
+
>(({ className, ...props }, ref) => (
|
| 119 |
+
<SheetPrimitive.Description
|
| 120 |
+
ref={ref}
|
| 121 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 122 |
+
{...props}
|
| 123 |
+
/>
|
| 124 |
+
))
|
| 125 |
+
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
| 126 |
+
|
| 127 |
+
export {
|
| 128 |
+
Sheet,
|
| 129 |
+
SheetPortal,
|
| 130 |
+
SheetOverlay,
|
| 131 |
+
SheetTrigger,
|
| 132 |
+
SheetClose,
|
| 133 |
+
SheetContent,
|
| 134 |
+
SheetHeader,
|
| 135 |
+
SheetFooter,
|
| 136 |
+
SheetTitle,
|
| 137 |
+
SheetDescription,
|
| 138 |
+
}
|
src/components/ui/tabs.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
const Tabs = TabsPrimitive.Root
|
| 7 |
+
|
| 8 |
+
const TabsList = React.forwardRef<
|
| 9 |
+
React.ElementRef<typeof TabsPrimitive.List>,
|
| 10 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
| 11 |
+
>(({ className, ...props }, ref) => (
|
| 12 |
+
<TabsPrimitive.List
|
| 13 |
+
ref={ref}
|
| 14 |
+
className={cn(
|
| 15 |
+
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
| 16 |
+
className
|
| 17 |
+
)}
|
| 18 |
+
{...props}
|
| 19 |
+
/>
|
| 20 |
+
))
|
| 21 |
+
TabsList.displayName = TabsPrimitive.List.displayName
|
| 22 |
+
|
| 23 |
+
const TabsTrigger = React.forwardRef<
|
| 24 |
+
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
| 25 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
| 26 |
+
>(({ className, ...props }, ref) => (
|
| 27 |
+
<TabsPrimitive.Trigger
|
| 28 |
+
ref={ref}
|
| 29 |
+
className={cn(
|
| 30 |
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
| 31 |
+
className
|
| 32 |
+
)}
|
| 33 |
+
{...props}
|
| 34 |
+
/>
|
| 35 |
+
))
|
| 36 |
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
| 37 |
+
|
| 38 |
+
const TabsContent = React.forwardRef<
|
| 39 |
+
React.ElementRef<typeof TabsPrimitive.Content>,
|
| 40 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
| 41 |
+
>(({ className, ...props }, ref) => (
|
| 42 |
+
<TabsPrimitive.Content
|
| 43 |
+
ref={ref}
|
| 44 |
+
className={cn(
|
| 45 |
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
))
|
| 51 |
+
TabsContent.displayName = TabsPrimitive.Content.displayName
|
| 52 |
+
|
| 53 |
+
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
src/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
| 6 |
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div id="root"></div>
|
| 12 |
+
<script type="module" src="/client/main.tsx"></script>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
src/lib/api-url.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function getBaseUrl(): string {
|
| 2 |
+
// Check if we're in browser
|
| 3 |
+
if (typeof window !== 'undefined') {
|
| 4 |
+
if (window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
| 5 |
+
return process.env.DOMAIN_URL;
|
| 6 |
+
}
|
| 7 |
+
return `${window.location.protocol}//${window.location.host}`;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
return process.env.NODE_ENV === 'production'
|
| 11 |
+
? process.env.DOMAIN_URL
|
| 12 |
+
: 'http://localhost:5000';
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function getApiUrl(endpoint: string): string {
|
| 16 |
+
const baseUrl = getBaseUrl();
|
| 17 |
+
// Remove leading slash if present to avoid double slashes
|
| 18 |
+
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
| 19 |
+
return `${baseUrl}/api${cleanEndpoint}`;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function getDisplayUrl(): string {
|
| 23 |
+
const baseUrl = getBaseUrl();
|
| 24 |
+
return baseUrl.replace(/^https?:\/\//, '');
|
| 25 |
+
}
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from "clsx";
|
| 2 |
+
import { twMerge } from "tailwind-merge";
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
src/public/favicon.svg
ADDED
|
|
src/server/index.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express, { type Request, Response, NextFunction } from "express";
|
| 2 |
+
import { serveStatic } from "./static";
|
| 3 |
+
import { createServer } from "http";
|
| 4 |
+
import { initPluginLoader, getPluginLoader } from "./plugin-loader";
|
| 5 |
+
import { join } from "path";
|
| 6 |
+
import { initStatsTracker, getStatsTracker } from "./lib/stats-tracker";
|
| 7 |
+
|
| 8 |
+
const app = express();
|
| 9 |
+
const httpServer = createServer(app);
|
| 10 |
+
|
| 11 |
+
declare module "http" {
|
| 12 |
+
interface IncomingMessage {
|
| 13 |
+
rawBody: unknown;
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
app.use(
|
| 18 |
+
express.json({
|
| 19 |
+
verify: (req, _res, buf) => {
|
| 20 |
+
req.rawBody = buf;
|
| 21 |
+
},
|
| 22 |
+
}),
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
app.use(express.urlencoded({ extended: false }));
|
| 26 |
+
|
| 27 |
+
export function log(message: string, source = "express") {
|
| 28 |
+
const formattedTime = new Date().toLocaleTimeString("en-US", {
|
| 29 |
+
hour: "numeric",
|
| 30 |
+
minute: "2-digit",
|
| 31 |
+
second: "2-digit",
|
| 32 |
+
hour12: true,
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
console.log(`${formattedTime} [${source}] ${message}`);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface RateLimitStore {
|
| 39 |
+
[key: string]: {
|
| 40 |
+
count: number;
|
| 41 |
+
resetTime: number;
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const rateLimitStore: RateLimitStore = {};
|
| 46 |
+
const RATE_LIMIT = 25;
|
| 47 |
+
const WINDOW_MS = 60 * 1000;
|
| 48 |
+
|
| 49 |
+
setInterval(() => {
|
| 50 |
+
const now = Date.now();
|
| 51 |
+
Object.keys(rateLimitStore).forEach((key) => {
|
| 52 |
+
if (rateLimitStore[key].resetTime < now) {
|
| 53 |
+
delete rateLimitStore[key];
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
}, 5 * 60 * 1000);
|
| 57 |
+
|
| 58 |
+
app.use("/api", (req: Request, res: Response, next: NextFunction) => {
|
| 59 |
+
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
| 60 |
+
const now = Date.now();
|
| 61 |
+
|
| 62 |
+
if (!rateLimitStore[clientIp]) {
|
| 63 |
+
rateLimitStore[clientIp] = {
|
| 64 |
+
count: 1,
|
| 65 |
+
resetTime: now + WINDOW_MS,
|
| 66 |
+
};
|
| 67 |
+
return next();
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const clientData = rateLimitStore[clientIp];
|
| 71 |
+
|
| 72 |
+
if (now > clientData.resetTime) {
|
| 73 |
+
clientData.count = 1;
|
| 74 |
+
clientData.resetTime = now + WINDOW_MS;
|
| 75 |
+
return next();
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
clientData.count++;
|
| 79 |
+
|
| 80 |
+
const remaining = Math.max(0, RATE_LIMIT - clientData.count);
|
| 81 |
+
const resetInSeconds = Math.ceil((clientData.resetTime - now) / 1000);
|
| 82 |
+
|
| 83 |
+
res.setHeader("X-RateLimit-Limit", RATE_LIMIT.toString());
|
| 84 |
+
res.setHeader("X-RateLimit-Remaining", remaining.toString());
|
| 85 |
+
res.setHeader("X-RateLimit-Reset", resetInSeconds.toString());
|
| 86 |
+
|
| 87 |
+
if (clientData.count > RATE_LIMIT) {
|
| 88 |
+
log(`Rate limit exceeded for IP: ${clientIp}`, "rate-limit");
|
| 89 |
+
return res.status(429).json({
|
| 90 |
+
message: "Too many requests, please try again later.",
|
| 91 |
+
retryAfter: resetInSeconds,
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
next();
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
app.use((req, res, next) => {
|
| 99 |
+
const start = Date.now();
|
| 100 |
+
const path = req.path;
|
| 101 |
+
let capturedJsonResponse: Record<string, any> | undefined = undefined;
|
| 102 |
+
|
| 103 |
+
const originalResJson = res.json;
|
| 104 |
+
res.json = function (bodyJson, ...args) {
|
| 105 |
+
capturedJsonResponse = bodyJson;
|
| 106 |
+
return originalResJson.apply(res, [bodyJson, ...args]);
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
res.on("finish", () => {
|
| 110 |
+
const duration = Date.now() - start;
|
| 111 |
+
if (path.startsWith("/api")) {
|
| 112 |
+
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
|
| 113 |
+
if (capturedJsonResponse) {
|
| 114 |
+
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
log(logLine);
|
| 118 |
+
|
| 119 |
+
const excludedPaths = [
|
| 120 |
+
'/api/plugins',
|
| 121 |
+
'/api/stats',
|
| 122 |
+
'/api/categories',
|
| 123 |
+
'/docs'
|
| 124 |
+
];
|
| 125 |
+
|
| 126 |
+
const isPluginEndpoint = !excludedPaths.some(excluded => path.startsWith(excluded));
|
| 127 |
+
|
| 128 |
+
if (isPluginEndpoint) {
|
| 129 |
+
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
| 130 |
+
getStatsTracker().trackRequest(path, res.statusCode, clientIp);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
next();
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
(async () => {
|
| 139 |
+
initStatsTracker();
|
| 140 |
+
log("Stats tracker initialized");
|
| 141 |
+
|
| 142 |
+
const pluginsDir = join(process.cwd(), "src/server/plugins");
|
| 143 |
+
const pluginLoader = initPluginLoader(pluginsDir);
|
| 144 |
+
|
| 145 |
+
const isDev = process.env.NODE_ENV === "development";
|
| 146 |
+
await pluginLoader.loadPlugins(app, isDev);
|
| 147 |
+
|
| 148 |
+
app.get("/api/plugins", (req, res) => {
|
| 149 |
+
const metadata = getPluginLoader().getPluginMetadata();
|
| 150 |
+
res.json({
|
| 151 |
+
success: true,
|
| 152 |
+
count: metadata.length,
|
| 153 |
+
plugins: metadata,
|
| 154 |
+
});
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
app.get("/api/plugins/category/:category", (req, res) => {
|
| 158 |
+
const { category } = req.params;
|
| 159 |
+
const allPlugins = getPluginLoader().getPluginMetadata();
|
| 160 |
+
const filtered = allPlugins.filter(p =>
|
| 161 |
+
p.category.includes(category)
|
| 162 |
+
);
|
| 163 |
+
|
| 164 |
+
res.json({
|
| 165 |
+
success: true,
|
| 166 |
+
category,
|
| 167 |
+
count: filtered.length,
|
| 168 |
+
plugins: filtered,
|
| 169 |
+
});
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
app.get("/api/stats", (req, res) => {
|
| 173 |
+
const globalStats = getStatsTracker().getGlobalStats();
|
| 174 |
+
const topEndpoints = getStatsTracker().getTopEndpoints(5);
|
| 175 |
+
|
| 176 |
+
res.json({
|
| 177 |
+
success: true,
|
| 178 |
+
stats: {
|
| 179 |
+
global: globalStats,
|
| 180 |
+
topEndpoints,
|
| 181 |
+
},
|
| 182 |
+
});
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
app.get("/api/stats/visitors", (req, res) => {
|
| 186 |
+
const chartData = getStatsTracker().getVisitorChartData();
|
| 187 |
+
|
| 188 |
+
res.json({
|
| 189 |
+
success: true,
|
| 190 |
+
data: chartData,
|
| 191 |
+
});
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
app.get("/api/categories", (req, res) => {
|
| 195 |
+
const allPlugins = getPluginLoader().getPluginMetadata();
|
| 196 |
+
const categoriesMap = new Map<string, number>();
|
| 197 |
+
|
| 198 |
+
allPlugins.forEach(plugin => {
|
| 199 |
+
plugin.category.forEach(cat => {
|
| 200 |
+
categoriesMap.set(cat, (categoriesMap.get(cat) || 0) + 1);
|
| 201 |
+
});
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
const categories = Array.from(categoriesMap.entries()).map(([name, count]) => ({
|
| 205 |
+
name,
|
| 206 |
+
count,
|
| 207 |
+
}));
|
| 208 |
+
|
| 209 |
+
res.json({
|
| 210 |
+
success: true,
|
| 211 |
+
categories,
|
| 212 |
+
});
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
| 216 |
+
const status = err.status || err.statusCode || 500;
|
| 217 |
+
const message = err.message || "Internal Server Error";
|
| 218 |
+
|
| 219 |
+
res.status(status).json({ message });
|
| 220 |
+
throw err;
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
if (process.env.NODE_ENV === "production") {
|
| 224 |
+
serveStatic(app);
|
| 225 |
+
} else {
|
| 226 |
+
const { setupVite } = await import("./vite");
|
| 227 |
+
await setupVite(httpServer, app);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
app.use((req: Request, res: Response, next: NextFunction) => {
|
| 231 |
+
if (req.path.startsWith("/api")) {
|
| 232 |
+
return res.status(404).json({
|
| 233 |
+
message: "API endpoint not found",
|
| 234 |
+
path: req.path,
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
next();
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
const port = parseInt(process.env.PORT || "7860", 10);
|
| 241 |
+
httpServer.listen(
|
| 242 |
+
{
|
| 243 |
+
port,
|
| 244 |
+
host: "0.0.0.0",
|
| 245 |
+
reusePort: true,
|
| 246 |
+
},
|
| 247 |
+
() => {
|
| 248 |
+
log(`serving on port ${port}`);
|
| 249 |
+
},
|
| 250 |
+
);
|
| 251 |
+
|
| 252 |
+
process.on('uncaughtException', (error: Error) => {
|
| 253 |
+
log(`Uncaught Exception: ${error.message}`, 'error');
|
| 254 |
+
console.error(error.stack);
|
| 255 |
+
});
|
| 256 |
+
|
| 257 |
+
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
|
| 258 |
+
log(`Unhandled Rejection at: ${promise}, reason: ${reason}`, 'error');
|
| 259 |
+
console.error(reason);
|
| 260 |
+
});
|
| 261 |
+
})();
|
src/server/lib/response-helper.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Send standardized success response
|
| 3 |
+
*/
|
| 4 |
+
export function sendSuccess(
|
| 5 |
+
res,
|
| 6 |
+
data,
|
| 7 |
+
message,
|
| 8 |
+
statusCode = 200
|
| 9 |
+
) {
|
| 10 |
+
const response = {
|
| 11 |
+
status: statusCode,
|
| 12 |
+
author: "Ditzzy",
|
| 13 |
+
note: "Thank you for using this API!",
|
| 14 |
+
results: data,
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
if (message) {
|
| 18 |
+
response.message = message;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
return res.status(statusCode).json(response);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Send standardized error response
|
| 26 |
+
*/
|
| 27 |
+
export function sendError(
|
| 28 |
+
res,
|
| 29 |
+
statusCode,
|
| 30 |
+
message,
|
| 31 |
+
error
|
| 32 |
+
) {
|
| 33 |
+
const response = {
|
| 34 |
+
status: statusCode,
|
| 35 |
+
message,
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
if (error) {
|
| 39 |
+
response.error = error;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return res.status(statusCode).json(response);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Common error responses
|
| 47 |
+
*/
|
| 48 |
+
export const ErrorResponses = {
|
| 49 |
+
badRequest: (res, message = "Bad request") =>
|
| 50 |
+
sendError(res, 400, message),
|
| 51 |
+
|
| 52 |
+
invalidUrl: (res, message = "Invalid URL") =>
|
| 53 |
+
sendError(res, 400, message),
|
| 54 |
+
|
| 55 |
+
missingParameter: (res, param) =>
|
| 56 |
+
sendError(res, 400, `Missing required parameter: ${param}`),
|
| 57 |
+
|
| 58 |
+
invalidParameter: (res, param, reason) =>
|
| 59 |
+
sendError(
|
| 60 |
+
res,
|
| 61 |
+
400,
|
| 62 |
+
`Invalid parameter: ${param}${reason ? ` - ${reason}` : ""}`
|
| 63 |
+
),
|
| 64 |
+
|
| 65 |
+
notFound: (res, message = "Resource not found") =>
|
| 66 |
+
sendError(res, 404, message),
|
| 67 |
+
|
| 68 |
+
serverError: (
|
| 69 |
+
res,
|
| 70 |
+
message = "An error occurred, please try again later."
|
| 71 |
+
) =>
|
| 72 |
+
sendError(res, 500, message),
|
| 73 |
+
|
| 74 |
+
tooManyRequests: (
|
| 75 |
+
res,
|
| 76 |
+
message = "Too many requests, please slow down."
|
| 77 |
+
) =>
|
| 78 |
+
sendError(res, 429, message),
|
| 79 |
+
|
| 80 |
+
serviceUnavailable: (
|
| 81 |
+
res,
|
| 82 |
+
message = "Service temporarily unavailable"
|
| 83 |
+
) =>
|
| 84 |
+
sendError(res, 503, message),
|
| 85 |
+
};
|
src/server/lib/stats-tracker.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface EndpointStats {
|
| 2 |
+
totalRequests: number;
|
| 3 |
+
successRequests: number;
|
| 4 |
+
failedRequests: number;
|
| 5 |
+
lastAccessed: number;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
interface VisitorData {
|
| 9 |
+
timestamp: number;
|
| 10 |
+
count: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface GlobalStats {
|
| 14 |
+
totalRequests: number;
|
| 15 |
+
totalSuccess: number;
|
| 16 |
+
totalFailed: number;
|
| 17 |
+
uniqueVisitors: Set<string>;
|
| 18 |
+
endpoints: Map<string, EndpointStats>;
|
| 19 |
+
startTime: number;
|
| 20 |
+
visitorsByHour: Map<number, Set<string>>;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
class StatsTracker {
|
| 24 |
+
private stats: GlobalStats;
|
| 25 |
+
|
| 26 |
+
constructor() {
|
| 27 |
+
this.stats = {
|
| 28 |
+
totalRequests: 0,
|
| 29 |
+
totalSuccess: 0,
|
| 30 |
+
totalFailed: 0,
|
| 31 |
+
uniqueVisitors: new Set(),
|
| 32 |
+
endpoints: new Map(),
|
| 33 |
+
startTime: Date.now(),
|
| 34 |
+
visitorsByHour: new Map(),
|
| 35 |
+
};
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
trackRequest(endpoint: string, statusCode: number, clientIp: string) {
|
| 39 |
+
this.stats.totalRequests++;
|
| 40 |
+
this.stats.uniqueVisitors.add(clientIp);
|
| 41 |
+
|
| 42 |
+
const currentHour = Math.floor(Date.now() / (1000 * 60 * 60));
|
| 43 |
+
if (!this.stats.visitorsByHour.has(currentHour)) {
|
| 44 |
+
this.stats.visitorsByHour.set(currentHour, new Set());
|
| 45 |
+
}
|
| 46 |
+
this.stats.visitorsByHour.get(currentHour)!.add(clientIp);
|
| 47 |
+
|
| 48 |
+
const cutoffHour = currentHour - 24;
|
| 49 |
+
Array.from(this.stats.visitorsByHour.keys()).forEach(hour => {
|
| 50 |
+
if (hour < cutoffHour) {
|
| 51 |
+
this.stats.visitorsByHour.delete(hour);
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
if (statusCode >= 200 && statusCode < 400) {
|
| 56 |
+
this.stats.totalSuccess++;
|
| 57 |
+
} else {
|
| 58 |
+
this.stats.totalFailed++;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (!this.stats.endpoints.has(endpoint)) {
|
| 62 |
+
this.stats.endpoints.set(endpoint, {
|
| 63 |
+
totalRequests: 0,
|
| 64 |
+
successRequests: 0,
|
| 65 |
+
failedRequests: 0,
|
| 66 |
+
lastAccessed: Date.now(),
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const endpointStats = this.stats.endpoints.get(endpoint)!;
|
| 71 |
+
endpointStats.totalRequests++;
|
| 72 |
+
endpointStats.lastAccessed = Date.now();
|
| 73 |
+
|
| 74 |
+
if (statusCode >= 200 && statusCode < 400) {
|
| 75 |
+
endpointStats.successRequests++;
|
| 76 |
+
} else {
|
| 77 |
+
endpointStats.failedRequests++;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
getGlobalStats() {
|
| 82 |
+
const uptime = Date.now() - this.stats.startTime;
|
| 83 |
+
const uptimeHours = Math.floor(uptime / (1000 * 60 * 60));
|
| 84 |
+
const uptimeDays = Math.floor(uptimeHours / 24);
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
totalRequests: this.stats.totalRequests,
|
| 88 |
+
totalSuccess: this.stats.totalSuccess,
|
| 89 |
+
totalFailed: this.stats.totalFailed,
|
| 90 |
+
uniqueVisitors: this.stats.uniqueVisitors.size,
|
| 91 |
+
successRate: this.stats.totalRequests > 0
|
| 92 |
+
? ((this.stats.totalSuccess / this.stats.totalRequests) * 100).toFixed(2)
|
| 93 |
+
: "0.00",
|
| 94 |
+
uptime: {
|
| 95 |
+
ms: uptime,
|
| 96 |
+
hours: uptimeHours,
|
| 97 |
+
days: uptimeDays,
|
| 98 |
+
formatted: uptimeDays > 0
|
| 99 |
+
? `${uptimeDays}d ${uptimeHours % 24}h`
|
| 100 |
+
: `${uptimeHours}h`,
|
| 101 |
+
},
|
| 102 |
+
};
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
getVisitorChartData(): VisitorData[] {
|
| 106 |
+
const currentHour = Math.floor(Date.now() / (1000 * 60 * 60));
|
| 107 |
+
const data: VisitorData[] = [];
|
| 108 |
+
|
| 109 |
+
for (let i = 23; i >= 0; i--) {
|
| 110 |
+
const hour = currentHour - i;
|
| 111 |
+
const visitors = this.stats.visitorsByHour.get(hour);
|
| 112 |
+
const timestamp = hour * 1000 * 60 * 60;
|
| 113 |
+
|
| 114 |
+
data.push({
|
| 115 |
+
timestamp,
|
| 116 |
+
count: visitors ? visitors.size : 0,
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return data;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
getEndpointStats(endpoint: string) {
|
| 124 |
+
return this.stats.endpoints.get(endpoint) || null;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
getAllEndpointStats() {
|
| 128 |
+
const result: Record<string, EndpointStats> = {};
|
| 129 |
+
this.stats.endpoints.forEach((stats, endpoint) => {
|
| 130 |
+
result[endpoint] = stats;
|
| 131 |
+
});
|
| 132 |
+
return result;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
getTopEndpoints(limit: number = 10) {
|
| 136 |
+
return Array.from(this.stats.endpoints.entries())
|
| 137 |
+
.map(([endpoint, stats]) => ({ endpoint, ...stats }))
|
| 138 |
+
.sort((a, b) => b.totalRequests - a.totalRequests)
|
| 139 |
+
.slice(0, limit);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
reset() {
|
| 143 |
+
this.stats = {
|
| 144 |
+
totalRequests: 0,
|
| 145 |
+
totalSuccess: 0,
|
| 146 |
+
totalFailed: 0,
|
| 147 |
+
uniqueVisitors: new Set(),
|
| 148 |
+
endpoints: new Map(),
|
| 149 |
+
startTime: Date.now(),
|
| 150 |
+
visitorsByHour: new Map(),
|
| 151 |
+
};
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
let statsTracker: StatsTracker;
|
| 156 |
+
|
| 157 |
+
export function initStatsTracker() {
|
| 158 |
+
statsTracker = new StatsTracker();
|
| 159 |
+
return statsTracker;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
export function getStatsTracker() {
|
| 163 |
+
if (!statsTracker) {
|
| 164 |
+
throw new Error("StatsTracker not initialized. Call initStatsTracker() first.");
|
| 165 |
+
}
|
| 166 |
+
return statsTracker;
|
| 167 |
+
}
|
src/server/plugin-loader.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Express, Router } from "express";
|
| 2 |
+
import { readdirSync, statSync, existsSync } from "fs";
|
| 3 |
+
import { join, extname, relative } from "path";
|
| 4 |
+
import { watch } from "chokidar";
|
| 5 |
+
import { pathToFileURL } from "url";
|
| 6 |
+
import { ApiPluginHandler, PluginMetadata, PluginRegistry } from "./types/plugin";
|
| 7 |
+
|
| 8 |
+
export class PluginLoader {
|
| 9 |
+
private pluginRegistry: PluginRegistry = {};
|
| 10 |
+
private pluginsDir: string;
|
| 11 |
+
private router: Router | null = null;
|
| 12 |
+
private app: Express | null = null;
|
| 13 |
+
private watcher: any = null;
|
| 14 |
+
|
| 15 |
+
constructor(pluginsDir: string) {
|
| 16 |
+
this.pluginsDir = pluginsDir;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
async loadPlugins(app: Express, enableHotReload = false) {
|
| 20 |
+
this.app = app;
|
| 21 |
+
this.router = Router();
|
| 22 |
+
|
| 23 |
+
await this.scanDirectory(this.pluginsDir, this.router);
|
| 24 |
+
app.use("/api", this.router);
|
| 25 |
+
|
| 26 |
+
console.log(`✅ Loaded ${Object.keys(this.pluginRegistry).length} plugins`);
|
| 27 |
+
|
| 28 |
+
if (enableHotReload) {
|
| 29 |
+
this.enableHotReload();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return this.pluginRegistry;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
private enableHotReload() {
|
| 36 |
+
if (this.watcher) {
|
| 37 |
+
console.log("Hot reload already enabled");
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
console.log("🔥 Hot reload enabled for plugins");
|
| 42 |
+
|
| 43 |
+
let reloadTimeout: NodeJS.Timeout | null = null;
|
| 44 |
+
|
| 45 |
+
this.watcher = watch(this.pluginsDir, {
|
| 46 |
+
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
| 47 |
+
persistent: true,
|
| 48 |
+
ignoreInitial: true,
|
| 49 |
+
awaitWriteFinish: {
|
| 50 |
+
stabilityThreshold: 500,
|
| 51 |
+
pollInterval: 100,
|
| 52 |
+
},
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const handleChange = (eventType: string, path: string) => {
|
| 56 |
+
console.log(`📝 Plugin ${eventType}: ${relative(this.pluginsDir, path)}`);
|
| 57 |
+
|
| 58 |
+
if (reloadTimeout) {
|
| 59 |
+
clearTimeout(reloadTimeout);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
reloadTimeout = setTimeout(() => {
|
| 63 |
+
this.reloadPlugins();
|
| 64 |
+
}, 200);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
this.watcher
|
| 68 |
+
.on("add", (path: string) => handleChange("added", path))
|
| 69 |
+
.on("change", (path: string) => handleChange("changed", path))
|
| 70 |
+
.on("unlink", (path: string) => {
|
| 71 |
+
console.log(`🗑️ Plugin removed: ${relative(this.pluginsDir, path)}`);
|
| 72 |
+
this.reloadPlugins();
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
private async reloadPlugins() {
|
| 77 |
+
if (!this.app || !this.router) return;
|
| 78 |
+
|
| 79 |
+
try {
|
| 80 |
+
console.log("🔄 Reloading plugins...");
|
| 81 |
+
const oldRegistry = { ...this.pluginRegistry };
|
| 82 |
+
const oldRouter = this.router;
|
| 83 |
+
this.pluginRegistry = {};
|
| 84 |
+
const newRouter = Router();
|
| 85 |
+
this.clearModuleCache(this.pluginsDir);
|
| 86 |
+
|
| 87 |
+
try {
|
| 88 |
+
await this.scanDirectory(this.pluginsDir, newRouter);
|
| 89 |
+
|
| 90 |
+
// If successful, replace old router with new one
|
| 91 |
+
this.removeOldRouter();
|
| 92 |
+
this.router = newRouter;
|
| 93 |
+
this.app.use("/api", this.router);
|
| 94 |
+
|
| 95 |
+
console.log(`✅ Successfully reloaded ${Object.keys(this.pluginRegistry).length} plugins`);
|
| 96 |
+
} catch (scanError) {
|
| 97 |
+
console.error("❌ Error scanning plugins, rolling back...");
|
| 98 |
+
this.pluginRegistry = oldRegistry;
|
| 99 |
+
this.router = oldRouter;
|
| 100 |
+
throw scanError;
|
| 101 |
+
}
|
| 102 |
+
} catch (error) {
|
| 103 |
+
console.error("❌ Error reloading plugins:", error);
|
| 104 |
+
console.log("⚠️ Keeping previous plugin configuration");
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
private removeOldRouter() {
|
| 109 |
+
if (!this.app) return;
|
| 110 |
+
|
| 111 |
+
try {
|
| 112 |
+
// Express 5 uses app._router differently
|
| 113 |
+
const stack = (this.app as any)._router?.stack || [];
|
| 114 |
+
|
| 115 |
+
for (let i = stack.length - 1; i >= 0; i--) {
|
| 116 |
+
const layer = stack[i];
|
| 117 |
+
if (layer.name === 'router' && layer.regexp.test('/api')) {
|
| 118 |
+
stack.splice(i, 1);
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
} catch (error) {
|
| 122 |
+
// if _router structure is different, just log warning
|
| 123 |
+
console.warn("⚠️ Could not remove old router, continuing anyway...");
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
private clearModuleCache(dirPath: string) {
|
| 128 |
+
if (!existsSync(dirPath)) return;
|
| 129 |
+
|
| 130 |
+
const items = readdirSync(dirPath);
|
| 131 |
+
|
| 132 |
+
for (const item of items) {
|
| 133 |
+
const fullPath = join(dirPath, item);
|
| 134 |
+
const stat = statSync(fullPath);
|
| 135 |
+
|
| 136 |
+
if (stat.isDirectory()) {
|
| 137 |
+
this.clearModuleCache(fullPath);
|
| 138 |
+
} else if (stat.isFile() && (extname(item) === ".ts" || extname(item) === ".js")) {
|
| 139 |
+
// In ES modules, we can't clear cache like CommonJS.
|
| 140 |
+
// Hot Reload also doesn't seem to have any effect on API serving.
|
| 141 |
+
// For now, Just log and mark as reload, We have to restart the server in "development" mode.
|
| 142 |
+
// TODO: Find another way. If hot reloading doesn't work, try restarting automatically.
|
| 143 |
+
const relativePath = relative(process.cwd(), fullPath);
|
| 144 |
+
console.log(`♻️ Marked for reload: ${relativePath}`);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
private async scanDirectory(dir: string, router: Router, categoryPath: string[] = []) {
|
| 150 |
+
try {
|
| 151 |
+
const items = readdirSync(dir);
|
| 152 |
+
|
| 153 |
+
for (const item of items) {
|
| 154 |
+
const fullPath = join(dir, item);
|
| 155 |
+
const stat = statSync(fullPath);
|
| 156 |
+
|
| 157 |
+
if (stat.isDirectory()) {
|
| 158 |
+
await this.scanDirectory(fullPath, router, [...categoryPath, item]);
|
| 159 |
+
} else if (stat.isFile() && (extname(item) === ".ts" || extname(item) === ".js")) {
|
| 160 |
+
await this.loadPlugin(fullPath, router, categoryPath);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
} catch (error) {
|
| 164 |
+
console.error(`❌ Error scanning directory ${dir}:`, error);
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
private isValidPluginMetadata(handler: ApiPluginHandler, fileName: string): { valid: boolean; reason?: string } {
|
| 169 |
+
if (!handler.category || !Array.isArray(handler.category) || handler.category.length === 0) {
|
| 170 |
+
return { valid: false, reason: 'category is missing or empty' };
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if (!handler.name || typeof handler.name !== 'string' || handler.name.trim() === '') {
|
| 174 |
+
return { valid: false, reason: 'name is missing or empty' };
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (!handler.description || typeof handler.description !== 'string' || handler.description.trim() === '') {
|
| 178 |
+
return { valid: false, reason: 'description is missing or empty' };
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
return { valid: true };
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
private async loadPlugin(filePath: string, router: Router, categoryPath: string[]) {
|
| 185 |
+
const fileName = relative(this.pluginsDir, filePath);
|
| 186 |
+
|
| 187 |
+
try {
|
| 188 |
+
const fileUrl = pathToFileURL(filePath).href;
|
| 189 |
+
const cacheBuster = `?update=${Date.now()}`;
|
| 190 |
+
const module = await import(fileUrl + cacheBuster);
|
| 191 |
+
|
| 192 |
+
const handler: ApiPluginHandler = module.default;
|
| 193 |
+
|
| 194 |
+
if (!handler || !handler.exec) {
|
| 195 |
+
console.warn(`⚠️ Skipping plugin '${fileName}': missing handler or exec function`);
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
if (!handler.method) {
|
| 200 |
+
console.warn(`⚠️ Skipping plugin '${fileName}': missing 'method' field`);
|
| 201 |
+
return;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if (!handler.alias || handler.alias.length === 0) {
|
| 205 |
+
console.warn(`⚠️ Skipping plugin '${fileName}': missing 'alias' array`);
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
if (typeof handler.exec !== 'function') {
|
| 210 |
+
console.warn(`⚠️ Skipping plugin '${fileName}': 'exec' must be a function`);
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
const metadataValidation = this.isValidPluginMetadata(handler, fileName);
|
| 215 |
+
const shouldShowInDocs = metadataValidation.valid;
|
| 216 |
+
|
| 217 |
+
if (!shouldShowInDocs) {
|
| 218 |
+
console.warn(`⚠️ Plugin '${fileName}' will be hidden from docs: ${metadataValidation.reason}`);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const basePath = handler.category && handler.category.length > 0
|
| 222 |
+
? `/${handler.category.join("/")}`
|
| 223 |
+
: "";
|
| 224 |
+
|
| 225 |
+
const primaryAlias = handler.alias[0];
|
| 226 |
+
const primaryEndpoint = basePath ? `${basePath}/${primaryAlias}` : `/${primaryAlias}`;
|
| 227 |
+
const method = handler.method.toLowerCase() as "get" | "post" | "put" | "delete" | "patch";
|
| 228 |
+
|
| 229 |
+
const wrappedExec = async (req: any, res: any, next: any) => {
|
| 230 |
+
try {
|
| 231 |
+
await handler.exec(req, res, next);
|
| 232 |
+
} catch (error) {
|
| 233 |
+
console.error(`❌ Error in plugin ${handler.name || 'unknown'}:`, error);
|
| 234 |
+
if (!res.headersSent) {
|
| 235 |
+
res.status(500).json({
|
| 236 |
+
success: false,
|
| 237 |
+
message: "Plugin execution error",
|
| 238 |
+
plugin: handler.name || 'unknown',
|
| 239 |
+
error: error instanceof Error ? error.message : "Unknown error",
|
| 240 |
+
});
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
for (const alias of handler.alias) {
|
| 246 |
+
const endpoint = basePath ? `${basePath}/${alias}` : `/${alias}`;
|
| 247 |
+
router[method](endpoint, wrappedExec);
|
| 248 |
+
console.log(`✓ [${handler.method}] ${endpoint} -> ${handler.name || 'unnamed'}`);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
if (shouldShowInDocs) {
|
| 252 |
+
const metadata: PluginMetadata = {
|
| 253 |
+
name: handler.name,
|
| 254 |
+
description: handler.description,
|
| 255 |
+
version: handler.version || "1.0.0",
|
| 256 |
+
category: handler.category,
|
| 257 |
+
method: handler.method,
|
| 258 |
+
endpoint: primaryEndpoint,
|
| 259 |
+
aliases: handler.alias,
|
| 260 |
+
tags: handler.tags || [],
|
| 261 |
+
parameters: handler.parameters || {
|
| 262 |
+
query: [],
|
| 263 |
+
body: [],
|
| 264 |
+
headers: [],
|
| 265 |
+
path: []
|
| 266 |
+
},
|
| 267 |
+
responses: handler.responses || {}
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
this.pluginRegistry[primaryEndpoint] = { handler, metadata };
|
| 271 |
+
}
|
| 272 |
+
} catch (error) {
|
| 273 |
+
console.error(`❌ Failed to load plugin '${fileName}':`, error instanceof Error ? error.message : error);
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
getPluginMetadata(): PluginMetadata[] {
|
| 278 |
+
return Object.values(this.pluginRegistry).map(p => p.metadata);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
getPluginRegistry(): PluginRegistry {
|
| 282 |
+
return this.pluginRegistry;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
stopHotReload() {
|
| 286 |
+
if (this.watcher) {
|
| 287 |
+
this.watcher.close();
|
| 288 |
+
this.watcher = null;
|
| 289 |
+
console.log("🛑 Hot reload stopped");
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
let pluginLoader: PluginLoader;
|
| 295 |
+
|
| 296 |
+
export function initPluginLoader(pluginsDir: string) {
|
| 297 |
+
pluginLoader = new PluginLoader(pluginsDir);
|
| 298 |
+
return pluginLoader;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
export function getPluginLoader() {
|
| 302 |
+
return pluginLoader;
|
| 303 |
+
}
|
src/server/plugins/data.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const handler = {
|
| 2 |
+
name: "Greet user",
|
| 3 |
+
description: "Greet the user",
|
| 4 |
+
method: "GET",
|
| 5 |
+
category: [],
|
| 6 |
+
alias: ["data"],
|
| 7 |
+
exec: async (req, res) => {
|
| 8 |
+
res.json({
|
| 9 |
+
status: 200,
|
| 10 |
+
message: "Welcome to DitzzyAPI, Lets get started by visit our documentation on: https://api.ditzzy.my.id/docs"
|
| 11 |
+
})
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default handler
|
src/server/plugins/downloader/tiktok.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from "axios";
|
| 2 |
+
import { sendSuccess, ErrorResponses } from "../../lib/response-helper.js";
|
| 3 |
+
|
| 4 |
+
/** @type {import("../../types/plugin").ApiPluginHandler} */
|
| 5 |
+
const handler = {
|
| 6 |
+
name: "TikTok Downloader",
|
| 7 |
+
description: "Download videos or slide photos from TikTok URLs. Supports both standard and HD quality downloads.",
|
| 8 |
+
version: "1.0.0",
|
| 9 |
+
method: "GET",
|
| 10 |
+
category: ["downloader"],
|
| 11 |
+
alias: ["tiktok", "tt"],
|
| 12 |
+
tags: ["social-media", "video", "downloader"],
|
| 13 |
+
|
| 14 |
+
parameters: {
|
| 15 |
+
query: [
|
| 16 |
+
{
|
| 17 |
+
name: "url",
|
| 18 |
+
type: "string",
|
| 19 |
+
required: true,
|
| 20 |
+
description: "TikTok video URL to download",
|
| 21 |
+
example: "https://www.tiktok.com/@username/video/1234567890",
|
| 22 |
+
pattern: "^https?:\\/\\/(www\\.|vm\\.)?tiktok\\.com\\/.+$"
|
| 23 |
+
}
|
| 24 |
+
],
|
| 25 |
+
body: [],
|
| 26 |
+
headers: []
|
| 27 |
+
},
|
| 28 |
+
|
| 29 |
+
responses: {
|
| 30 |
+
200: {
|
| 31 |
+
status: 200,
|
| 32 |
+
description: "Successfully retrieved TikTok video data",
|
| 33 |
+
example: {
|
| 34 |
+
status: 200,
|
| 35 |
+
author: "Ditzzy",
|
| 36 |
+
note: "Thank you for using this API!",
|
| 37 |
+
results: {
|
| 38 |
+
id: "1234567890",
|
| 39 |
+
title: "Video Title",
|
| 40 |
+
author: {
|
| 41 |
+
nickname: "Username",
|
| 42 |
+
unique_id: "username"
|
| 43 |
+
},
|
| 44 |
+
play: "https://video-url.com/video.mp4",
|
| 45 |
+
wmplay: "https://video-url.com/video-watermark.mp4",
|
| 46 |
+
hdplay: "https://video-url.com/video-hd.mp4",
|
| 47 |
+
music: "https://music-url.com/audio.mp3",
|
| 48 |
+
duration: 15,
|
| 49 |
+
create_time: 1234567890
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
},
|
| 53 |
+
400: {
|
| 54 |
+
status: 400,
|
| 55 |
+
description: "Invalid TikTok URL provided",
|
| 56 |
+
example: {
|
| 57 |
+
status: 400,
|
| 58 |
+
message: "Invalid URL - must be a valid TikTok URL"
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
+
404: {
|
| 62 |
+
status: 404,
|
| 63 |
+
description: "Missing required parameter",
|
| 64 |
+
example: {
|
| 65 |
+
status: 404,
|
| 66 |
+
message: "Missing required parameter: url"
|
| 67 |
+
}
|
| 68 |
+
},
|
| 69 |
+
500: {
|
| 70 |
+
status: 500,
|
| 71 |
+
description: "Server error or TikTok API unavailable",
|
| 72 |
+
example: {
|
| 73 |
+
status: 500,
|
| 74 |
+
message: "An error occurred, please try again later."
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
},
|
| 78 |
+
|
| 79 |
+
exec: async (req, res) => {
|
| 80 |
+
const { url } = req.query;
|
| 81 |
+
|
| 82 |
+
if (!url) {
|
| 83 |
+
return ErrorResponses.missingParameter(res, "url");
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (!url.match(/tiktok/gi)) {
|
| 87 |
+
return ErrorResponses.invalidUrl(res, "Invalid URL - must be a valid TikTok URL");
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
try {
|
| 91 |
+
const videoData = await fetchTikTokVideo(url);
|
| 92 |
+
return sendSuccess(res, videoData);
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error("TikTok download error:", error);
|
| 95 |
+
return ErrorResponses.serverError(res);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
export default handler;
|
| 101 |
+
|
| 102 |
+
async function fetchTikTokVideo(url) {
|
| 103 |
+
const encodedParams = new URLSearchParams();
|
| 104 |
+
encodedParams.set("url", url);
|
| 105 |
+
encodedParams.set("hd", "1");
|
| 106 |
+
|
| 107 |
+
const response = await axios({
|
| 108 |
+
method: "POST",
|
| 109 |
+
url: "https://tikwm.com/api/",
|
| 110 |
+
headers: {
|
| 111 |
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
| 112 |
+
"Cookie": "current_language=en",
|
| 113 |
+
"User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"
|
| 114 |
+
},
|
| 115 |
+
data: encodedParams
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
if (!response.data || !response.data.data) {
|
| 119 |
+
throw new Error("Invalid response from TikTok API");
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return response.data.data;
|
| 123 |
+
}
|
src/server/static.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express, { type Express } from "express";
|
| 2 |
+
import fs from "fs";
|
| 3 |
+
import path from "path";
|
| 4 |
+
|
| 5 |
+
export function serveStatic(app: Express) {
|
| 6 |
+
const distPath = path.resolve(__dirname, "public");
|
| 7 |
+
if (!fs.existsSync(distPath)) {
|
| 8 |
+
throw new Error(
|
| 9 |
+
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
| 10 |
+
);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
app.use(express.static(distPath));
|
| 14 |
+
|
| 15 |
+
// fall through to index.html if the file doesn't exist
|
| 16 |
+
app.use((req, res) => {
|
| 17 |
+
res.sendFile(path.resolve(distPath, "index.html"));
|
| 18 |
+
});
|
| 19 |
+
}
|
src/server/types/plugin.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Request, Response, NextFunction } from "express";
|
| 2 |
+
|
| 3 |
+
export interface PluginParameter {
|
| 4 |
+
name: string;
|
| 5 |
+
type: "string" | "number" | "boolean" | "array" | "object";
|
| 6 |
+
required: boolean;
|
| 7 |
+
description: string;
|
| 8 |
+
example?: any;
|
| 9 |
+
default?: any;
|
| 10 |
+
enum?: any[];
|
| 11 |
+
pattern?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export interface PluginResponse {
|
| 15 |
+
status: number;
|
| 16 |
+
description: string;
|
| 17 |
+
example: any;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export interface PluginParameters {
|
| 21 |
+
query?: PluginParameter[];
|
| 22 |
+
body?: PluginParameter[];
|
| 23 |
+
headers?: PluginParameter[];
|
| 24 |
+
path?: PluginParameter[];
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface ApiPluginHandler {
|
| 28 |
+
name: string;
|
| 29 |
+
description: string;
|
| 30 |
+
version: string;
|
| 31 |
+
category: string[];
|
| 32 |
+
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
| 33 |
+
alias: string[];
|
| 34 |
+
tags?: string[];
|
| 35 |
+
parameters?: PluginParameters;
|
| 36 |
+
responses?: {
|
| 37 |
+
[statusCode: number]: PluginResponse;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
exec: (req: Request, res: Response, next: NextFunction) => Promise<any> | any;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface PluginMetadata {
|
| 44 |
+
name: string;
|
| 45 |
+
description: string;
|
| 46 |
+
version: string;
|
| 47 |
+
category: string[];
|
| 48 |
+
method: string;
|
| 49 |
+
endpoint: string;
|
| 50 |
+
aliases: string[];
|
| 51 |
+
tags?: string[];
|
| 52 |
+
parameters?: PluginParameters;
|
| 53 |
+
responses?: {
|
| 54 |
+
[statusCode: number]: PluginResponse;
|
| 55 |
+
};
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export interface PluginRegistry {
|
| 59 |
+
[endpoint: string]: {
|
| 60 |
+
handler: ApiPluginHandler;
|
| 61 |
+
metadata: PluginMetadata;
|
| 62 |
+
};
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export interface ApiResponse<T = any> {
|
| 66 |
+
status: number;
|
| 67 |
+
message?: string;
|
| 68 |
+
author?: string;
|
| 69 |
+
note?: string;
|
| 70 |
+
results?: T;
|
| 71 |
+
error?: string;
|
| 72 |
+
}
|
src/server/vite.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type Express } from "express";
|
| 2 |
+
import { createServer as createViteServer, createLogger } from "vite";
|
| 3 |
+
import { type Server } from "http";
|
| 4 |
+
import viteConfig from "../../vite.config";
|
| 5 |
+
import fs from "fs";
|
| 6 |
+
import path from "path";
|
| 7 |
+
import { nanoid } from "nanoid";
|
| 8 |
+
|
| 9 |
+
const viteLogger = createLogger();
|
| 10 |
+
|
| 11 |
+
export async function setupVite(server: Server, app: Express) {
|
| 12 |
+
const serverOptions = {
|
| 13 |
+
middlewareMode: true,
|
| 14 |
+
hmr: { server, path: "/vite-hmr" },
|
| 15 |
+
allowedHosts: true as const,
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const vite = await createViteServer({
|
| 19 |
+
...viteConfig,
|
| 20 |
+
configFile: false,
|
| 21 |
+
customLogger: {
|
| 22 |
+
...viteLogger,
|
| 23 |
+
error: (msg, options) => {
|
| 24 |
+
viteLogger.error(msg, options);
|
| 25 |
+
process.exit(1);
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
server: serverOptions,
|
| 29 |
+
appType: "custom",
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
app.use(vite.middlewares);
|
| 33 |
+
|
| 34 |
+
app.use(async (req, res, next) => {
|
| 35 |
+
const url = req.originalUrl;
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
const clientTemplate = path.resolve(
|
| 39 |
+
import.meta.dirname,
|
| 40 |
+
"..",
|
| 41 |
+
"index.html",
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
// always reload the index.html file from disk incase it changes
|
| 45 |
+
let template = await fs.promises.readFile(clientTemplate, "utf-8");
|
| 46 |
+
template = template.replace(
|
| 47 |
+
`client="/client/main.tsx"`,
|
| 48 |
+
`client="/client/main.tsx?v=${nanoid()}"`,
|
| 49 |
+
);
|
| 50 |
+
const page = await vite.transformIndexHtml(url, template);
|
| 51 |
+
res.status(200).set({ "Content-Type": "text/html" }).end(page);
|
| 52 |
+
} catch (e) {
|
| 53 |
+
vite.ssrFixStacktrace(e as Error);
|
| 54 |
+
next(e);
|
| 55 |
+
}
|
| 56 |
+
});
|
| 57 |
+
}
|
tailwind.config.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
export default {
|
| 4 |
+
darkMode: ["class"],
|
| 5 |
+
content: ["./src/index.html", "./src/**/*.{js,jsx,ts,tsx}"],
|
| 6 |
+
theme: {
|
| 7 |
+
extend: {
|
| 8 |
+
borderRadius: {
|
| 9 |
+
lg: ".5625rem", /* 9px */
|
| 10 |
+
md: ".375rem", /* 6px */
|
| 11 |
+
sm: ".1875rem", /* 3px */
|
| 12 |
+
},
|
| 13 |
+
colors: {
|
| 14 |
+
// Flat
|
| 15 |
+
background: "hsl(var(--background) / <alpha-value>)",
|
| 16 |
+
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
| 17 |
+
border: "hsl(var(--border) / <alpha-value>)",
|
| 18 |
+
input: "hsl(var(--input) / <alpha-value>)",
|
| 19 |
+
card: {
|
| 20 |
+
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
| 21 |
+
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
|
| 22 |
+
border: "hsl(var(--card-border) / <alpha-value>)",
|
| 23 |
+
},
|
| 24 |
+
popover: {
|
| 25 |
+
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
| 26 |
+
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
|
| 27 |
+
border: "hsl(var(--popover-border) / <alpha-value>)",
|
| 28 |
+
},
|
| 29 |
+
primary: {
|
| 30 |
+
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
| 31 |
+
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
|
| 32 |
+
border: "var(--primary-border)",
|
| 33 |
+
},
|
| 34 |
+
secondary: {
|
| 35 |
+
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
| 36 |
+
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
|
| 37 |
+
border: "var(--secondary-border)",
|
| 38 |
+
},
|
| 39 |
+
muted: {
|
| 40 |
+
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
| 41 |
+
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
|
| 42 |
+
border: "var(--muted-border)",
|
| 43 |
+
},
|
| 44 |
+
accent: {
|
| 45 |
+
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
| 46 |
+
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
|
| 47 |
+
border: "var(--accent-border)",
|
| 48 |
+
},
|
| 49 |
+
destructive: {
|
| 50 |
+
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
| 51 |
+
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
|
| 52 |
+
border: "var(--destructive-border)",
|
| 53 |
+
},
|
| 54 |
+
ring: "hsl(var(--ring) / <alpha-value>)",
|
| 55 |
+
chart: {
|
| 56 |
+
"1": "hsl(var(--chart-1) / <alpha-value>)",
|
| 57 |
+
"2": "hsl(var(--chart-2) / <alpha-value>)",
|
| 58 |
+
"3": "hsl(var(--chart-3) / <alpha-value>)",
|
| 59 |
+
"4": "hsl(var(--chart-4) / <alpha-value>)",
|
| 60 |
+
"5": "hsl(var(--chart-5) / <alpha-value>)",
|
| 61 |
+
},
|
| 62 |
+
sidebar: {
|
| 63 |
+
ring: "hsl(var(--sidebar-ring) / <alpha-value>)",
|
| 64 |
+
DEFAULT: "hsl(var(--sidebar) / <alpha-value>)",
|
| 65 |
+
foreground: "hsl(var(--sidebar-foreground) / <alpha-value>)",
|
| 66 |
+
border: "hsl(var(--sidebar-border) / <alpha-value>)",
|
| 67 |
+
},
|
| 68 |
+
"sidebar-primary": {
|
| 69 |
+
DEFAULT: "hsl(var(--sidebar-primary) / <alpha-value>)",
|
| 70 |
+
foreground: "hsl(var(--sidebar-primary-foreground) / <alpha-value>)",
|
| 71 |
+
border: "var(--sidebar-primary-border)",
|
| 72 |
+
},
|
| 73 |
+
"sidebar-accent": {
|
| 74 |
+
DEFAULT: "hsl(var(--sidebar-accent) / <alpha-value>)",
|
| 75 |
+
foreground: "hsl(var(--sidebar-accent-foreground) / <alpha-value>)",
|
| 76 |
+
border: "var(--sidebar-accent-border)"
|
| 77 |
+
},
|
| 78 |
+
status: {
|
| 79 |
+
online: "rgb(34 197 94)",
|
| 80 |
+
away: "rgb(245 158 11)",
|
| 81 |
+
busy: "rgb(239 68 68)",
|
| 82 |
+
offline: "rgb(156 163 175)",
|
| 83 |
+
},
|
| 84 |
+
},
|
| 85 |
+
fontFamily: {
|
| 86 |
+
sans: ["Inter", "sans-serif"],
|
| 87 |
+
display: ["Space Grotesk", "sans-serif"],
|
| 88 |
+
mono: ["JetBrains Mono", "monospace"],
|
| 89 |
+
},
|
| 90 |
+
keyframes: {
|
| 91 |
+
"accordion-down": {
|
| 92 |
+
from: { height: "0" },
|
| 93 |
+
to: { height: "var(--radix-accordion-content-height)" },
|
| 94 |
+
},
|
| 95 |
+
"accordion-up": {
|
| 96 |
+
from: { height: "var(--radix-accordion-content-height)" },
|
| 97 |
+
to: { height: "0" },
|
| 98 |
+
},
|
| 99 |
+
},
|
| 100 |
+
animation: {
|
| 101 |
+
"accordion-down": "accordion-down 0.2s ease-out",
|
| 102 |
+
"accordion-up": "accordion-up 0.2s ease-out",
|
| 103 |
+
},
|
| 104 |
+
},
|
| 105 |
+
},
|
| 106 |
+
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
| 107 |
+
} satisfies Config;
|
tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"include": ["src/**/*"],
|
| 3 |
+
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
|
| 4 |
+
"compilerOptions": {
|
| 5 |
+
"incremental": true,
|
| 6 |
+
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
|
| 7 |
+
"noEmit": true,
|
| 8 |
+
"module": "ESNext",
|
| 9 |
+
"strict": true,
|
| 10 |
+
"lib": ["esnext", "dom", "dom.iterable"],
|
| 11 |
+
"jsx": "preserve",
|
| 12 |
+
"esModuleInterop": true,
|
| 13 |
+
"skipLibCheck": true,
|
| 14 |
+
"allowImportingTsExtensions": true,
|
| 15 |
+
"allowSyntheticDefaultImports": true,
|
| 16 |
+
"moduleResolution": "bundler",
|
| 17 |
+
"baseUrl": ".",
|
| 18 |
+
"types": ["node", "vite/client"],
|
| 19 |
+
"paths": {
|
| 20 |
+
"@/*": ["./src/*"]
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite"
|
| 2 |
+
import react from "@vitejs/plugin-react"
|
| 3 |
+
import path from "node:path"
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
resolve: {
|
| 8 |
+
alias: {
|
| 9 |
+
"@": path.resolve(import.meta.dirname, "src"),
|
| 10 |
+
},
|
| 11 |
+
},
|
| 12 |
+
root: path.resolve(import.meta.dirname, "src"),
|
| 13 |
+
build: {
|
| 14 |
+
outDir: path.resolve(import.meta.dirname, "dist/public"),
|
| 15 |
+
emptyOutDir: true,
|
| 16 |
+
},
|
| 17 |
+
server: {
|
| 18 |
+
fs: {
|
| 19 |
+
strict: true,
|
| 20 |
+
deny: ["**/.*"],
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
})
|