@kozojs/cli 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +536 -361
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -456,6 +456,7 @@ async function scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep, ru
|
|
|
456
456
|
"@hono/node-server": "^1.13.0",
|
|
457
457
|
hono: "^4.6.0",
|
|
458
458
|
zod: "^3.23.0",
|
|
459
|
+
dotenv: "^16.4.0",
|
|
459
460
|
...hasDb && { "drizzle-orm": "^0.36.0" },
|
|
460
461
|
...database === "postgresql" && { postgres: "^3.4.0" },
|
|
461
462
|
...database === "mysql" && { mysql2: "^3.11.0" },
|
|
@@ -1383,7 +1384,7 @@ jobs:
|
|
|
1383
1384
|
async function scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth, frontend, extras, template) {
|
|
1384
1385
|
const hasDb = database !== "none";
|
|
1385
1386
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "routes"));
|
|
1386
|
-
|
|
1387
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "data"));
|
|
1387
1388
|
if (hasDb) await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "db"));
|
|
1388
1389
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "web", "src", "lib"));
|
|
1389
1390
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, ".vscode"));
|
|
@@ -1401,7 +1402,7 @@ async function scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, ru
|
|
|
1401
1402
|
`);
|
|
1402
1403
|
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".gitignore"), "node_modules/\ndist/\n.env\n*.log\n");
|
|
1403
1404
|
await scaffoldFullstackApi(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth);
|
|
1404
|
-
await scaffoldFullstackWeb(projectDir, projectName, frontend);
|
|
1405
|
+
await scaffoldFullstackWeb(projectDir, projectName, frontend, auth);
|
|
1405
1406
|
await scaffoldFullstackReadme(projectDir, projectName);
|
|
1406
1407
|
if (database !== "none" && database !== "sqlite") await createDockerCompose(projectDir, projectName, database, dbPort);
|
|
1407
1408
|
if (extras.includes("docker")) await createDockerfile(import_node_path.default.join(projectDir, "apps", "api"), runtime);
|
|
@@ -1836,22 +1837,19 @@ export function registerToolRoutes(app: Kozo) {
|
|
|
1836
1837
|
}
|
|
1837
1838
|
`);
|
|
1838
1839
|
}
|
|
1839
|
-
async function scaffoldFullstackWeb(projectDir, projectName, frontend) {
|
|
1840
|
+
async function scaffoldFullstackWeb(projectDir, projectName, frontend, auth = false) {
|
|
1840
1841
|
const webDir = import_node_path.default.join(projectDir, "apps", "web");
|
|
1842
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(webDir, "src", "lib"));
|
|
1843
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(webDir, "src", "pages"));
|
|
1841
1844
|
const packageJson = {
|
|
1842
1845
|
name: `@${projectName}/web`,
|
|
1843
1846
|
version: "1.0.0",
|
|
1844
1847
|
type: "module",
|
|
1845
|
-
scripts: {
|
|
1846
|
-
dev: "vite",
|
|
1847
|
-
build: "vite build",
|
|
1848
|
-
preview: "vite preview"
|
|
1849
|
-
},
|
|
1848
|
+
scripts: { dev: "vite", build: "vite build", preview: "vite preview" },
|
|
1850
1849
|
dependencies: {
|
|
1851
1850
|
react: "^18.2.0",
|
|
1852
1851
|
"react-dom": "^18.2.0",
|
|
1853
1852
|
"@tanstack/react-query": "^5.0.0",
|
|
1854
|
-
hono: "^4.6.0",
|
|
1855
1853
|
"lucide-react": "^0.460.0"
|
|
1856
1854
|
},
|
|
1857
1855
|
devDependencies: {
|
|
@@ -1865,6 +1863,24 @@ async function scaffoldFullstackWeb(projectDir, projectName, frontend) {
|
|
|
1865
1863
|
}
|
|
1866
1864
|
};
|
|
1867
1865
|
await import_fs_extra.default.writeJSON(import_node_path.default.join(webDir, "package.json"), packageJson, { spaces: 2 });
|
|
1866
|
+
await import_fs_extra.default.writeJSON(import_node_path.default.join(webDir, "tsconfig.json"), {
|
|
1867
|
+
compilerOptions: {
|
|
1868
|
+
target: "ES2020",
|
|
1869
|
+
lib: ["ES2020", "DOM", "DOM.Iterable"],
|
|
1870
|
+
module: "ESNext",
|
|
1871
|
+
skipLibCheck: true,
|
|
1872
|
+
moduleResolution: "bundler",
|
|
1873
|
+
allowImportingTsExtensions: true,
|
|
1874
|
+
resolveJsonModule: true,
|
|
1875
|
+
isolatedModules: true,
|
|
1876
|
+
noEmit: true,
|
|
1877
|
+
jsx: "react-jsx",
|
|
1878
|
+
strict: true,
|
|
1879
|
+
noUnusedLocals: true,
|
|
1880
|
+
noUnusedParameters: true
|
|
1881
|
+
},
|
|
1882
|
+
include: ["src"]
|
|
1883
|
+
}, { spaces: 2 });
|
|
1868
1884
|
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "vite.config.ts"), `import { defineConfig } from 'vite';
|
|
1869
1885
|
import react from '@vitejs/plugin-react';
|
|
1870
1886
|
import tailwindcss from '@tailwindcss/vite';
|
|
@@ -1872,8 +1888,14 @@ import tailwindcss from '@tailwindcss/vite';
|
|
|
1872
1888
|
export default defineConfig({
|
|
1873
1889
|
plugins: [react(), tailwindcss()],
|
|
1874
1890
|
server: {
|
|
1891
|
+
port: 5173,
|
|
1875
1892
|
proxy: {
|
|
1876
|
-
'/api': 'http://localhost:3000',
|
|
1893
|
+
'/api': { target: 'http://localhost:3000', changeOrigin: true },
|
|
1894
|
+
'/health': { target: 'http://localhost:3000', changeOrigin: true },
|
|
1895
|
+
'/auth': { target: 'http://localhost:3000', changeOrigin: true },
|
|
1896
|
+
'/users': { target: 'http://localhost:3000', changeOrigin: true },
|
|
1897
|
+
'/posts': { target: 'http://localhost:3000', changeOrigin: true },
|
|
1898
|
+
'/stats': { target: 'http://localhost:3000', changeOrigin: true },
|
|
1877
1899
|
},
|
|
1878
1900
|
},
|
|
1879
1901
|
});
|
|
@@ -1893,21 +1915,45 @@ export default defineConfig({
|
|
|
1893
1915
|
`);
|
|
1894
1916
|
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "index.css"), `@import "tailwindcss";
|
|
1895
1917
|
|
|
1896
|
-
body {
|
|
1897
|
-
|
|
1898
|
-
color: rgb(241 245 249);
|
|
1899
|
-
}
|
|
1918
|
+
body { background-color: rgb(15 23 42); color: rgb(241 245 249); font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
1919
|
+
* { box-sizing: border-box; }
|
|
1900
1920
|
`);
|
|
1901
|
-
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "lib", "
|
|
1902
|
-
import type { AppType } from '@${projectName}/api';
|
|
1921
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "lib", "api.ts"), `const API_BASE = '';
|
|
1903
1922
|
|
|
1904
|
-
|
|
1905
|
-
|
|
1923
|
+
${auth ? `export function getToken(): string | null {
|
|
1924
|
+
return localStorage.getItem('kozo_token');
|
|
1925
|
+
}
|
|
1906
1926
|
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1927
|
+
export function setToken(token: string): void {
|
|
1928
|
+
localStorage.setItem('kozo_token', token);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
export function clearToken(): void {
|
|
1932
|
+
localStorage.removeItem('kozo_token');
|
|
1933
|
+
}
|
|
1934
|
+
` : ""}
|
|
1935
|
+
export interface ApiResult<T = unknown> {
|
|
1936
|
+
data: T;
|
|
1937
|
+
status: number;
|
|
1938
|
+
ms: number;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
export async function apiFetch<T = unknown>(
|
|
1942
|
+
path: string,
|
|
1943
|
+
options: RequestInit = {},
|
|
1944
|
+
): Promise<ApiResult<T>> {
|
|
1945
|
+
const start = performance.now();
|
|
1946
|
+
const headers: Record<string, string> = {
|
|
1947
|
+
'Content-Type': 'application/json',
|
|
1948
|
+
...(options.headers as Record<string, string> ?? {}),
|
|
1949
|
+
};
|
|
1950
|
+
${auth ? ` const token = getToken();
|
|
1951
|
+
if (token) headers['Authorization'] = \`Bearer \${token}\`;
|
|
1952
|
+
` : ""}
|
|
1953
|
+
const res = await fetch(\`\${API_BASE}\${path}\`, { ...options, headers });
|
|
1954
|
+
const data = await res.json() as T;
|
|
1955
|
+
return { data, status: res.status, ms: Math.round(performance.now() - start) };
|
|
1956
|
+
}
|
|
1911
1957
|
`);
|
|
1912
1958
|
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "main.tsx"), `import React from 'react';
|
|
1913
1959
|
import ReactDOM from 'react-dom/client';
|
|
@@ -1916,12 +1962,7 @@ import App from './App';
|
|
|
1916
1962
|
import './index.css';
|
|
1917
1963
|
|
|
1918
1964
|
const queryClient = new QueryClient({
|
|
1919
|
-
defaultOptions: {
|
|
1920
|
-
queries: {
|
|
1921
|
-
refetchOnWindowFocus: false,
|
|
1922
|
-
retry: 1,
|
|
1923
|
-
},
|
|
1924
|
-
},
|
|
1965
|
+
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
|
|
1925
1966
|
});
|
|
1926
1967
|
|
|
1927
1968
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
@@ -1932,417 +1973,551 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
1932
1973
|
</React.StrictMode>
|
|
1933
1974
|
);
|
|
1934
1975
|
`);
|
|
1935
|
-
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "
|
|
1976
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Dashboard.tsx"), generateDashboardPage());
|
|
1977
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Users.tsx"), generateUsersPage());
|
|
1978
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Posts.tsx"), generatePostsPage());
|
|
1979
|
+
if (auth) {
|
|
1980
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Login.tsx"), generateLoginPage());
|
|
1981
|
+
}
|
|
1982
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "App.tsx"), generateAppTsx(projectName, auth));
|
|
1936
1983
|
}
|
|
1937
|
-
function
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
import {
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
}
|
|
1984
|
+
function generateAppTsx(projectName, auth) {
|
|
1985
|
+
const authImports = auth ? `import { getToken, clearToken } from './lib/api';` : "";
|
|
1986
|
+
const loginImport = auth ? `import Login from './pages/Login';` : "";
|
|
1987
|
+
return `import { useState${auth ? ", useEffect" : ""} } from 'react';
|
|
1988
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
1989
|
+
import { LayoutDashboard, Users, FileText, Server, LogOut } from 'lucide-react';
|
|
1990
|
+
${authImports}
|
|
1991
|
+
${loginImport}
|
|
1992
|
+
import Dashboard from './pages/Dashboard';
|
|
1993
|
+
import UsersPage from './pages/Users';
|
|
1994
|
+
import PostsPage from './pages/Posts';
|
|
1995
|
+
|
|
1996
|
+
type Page = 'dashboard' | 'users' | 'posts';
|
|
1944
1997
|
|
|
1945
|
-
|
|
1998
|
+
export default function App() {
|
|
1999
|
+
const [page, setPage] = useState<Page>('dashboard');
|
|
2000
|
+
${auth ? ` const [token, setToken] = useState<string | null>(() => getToken());
|
|
2001
|
+
const queryClient = useQueryClient();
|
|
1946
2002
|
|
|
1947
|
-
|
|
2003
|
+
useEffect(() => { if (token) { /* token refreshed */ } }, [token]);
|
|
1948
2004
|
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2005
|
+
const handleLogin = (t: string) => setToken(t);
|
|
2006
|
+
const handleLogout = () => {
|
|
2007
|
+
clearToken();
|
|
2008
|
+
setToken(null);
|
|
2009
|
+
queryClient.clear();
|
|
2010
|
+
};
|
|
2011
|
+
|
|
2012
|
+
if (!token) return <Login onLogin={handleLogin} />;
|
|
2013
|
+
` : ` const queryClient = useQueryClient();
|
|
2014
|
+
`}
|
|
2015
|
+
const nav = [
|
|
2016
|
+
{ id: 'dashboard' as Page, label: 'Dashboard', icon: LayoutDashboard },
|
|
2017
|
+
{ id: 'users' as Page, label: 'Users', icon: Users },
|
|
2018
|
+
{ id: 'posts' as Page, label: 'Posts', icon: FileText },
|
|
2019
|
+
];
|
|
2020
|
+
|
|
2021
|
+
return (
|
|
2022
|
+
<div className="flex min-h-screen">
|
|
2023
|
+
{/* Sidebar */}
|
|
2024
|
+
<aside className="w-56 bg-slate-900 border-r border-slate-800 flex flex-col p-4">
|
|
2025
|
+
<div className="flex items-center gap-2 mb-8 px-2">
|
|
2026
|
+
<Server className="w-5 h-5 text-blue-400" />
|
|
2027
|
+
<span className="font-bold text-slate-200">${projectName}</span>
|
|
2028
|
+
</div>
|
|
2029
|
+
<nav className="flex-1 space-y-1">
|
|
2030
|
+
{nav.map(({ id, label, icon: Icon }) => (
|
|
2031
|
+
<button
|
|
2032
|
+
key={id}
|
|
2033
|
+
onClick={() => setPage(id)}
|
|
2034
|
+
className={\`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition \${
|
|
2035
|
+
page === id
|
|
2036
|
+
? 'bg-blue-600 text-white'
|
|
2037
|
+
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
|
2038
|
+
}\`}
|
|
2039
|
+
>
|
|
2040
|
+
<Icon className="w-4 h-4" />
|
|
2041
|
+
{label}
|
|
2042
|
+
</button>
|
|
2043
|
+
))}
|
|
2044
|
+
</nav>
|
|
2045
|
+
${auth ? ` <button
|
|
2046
|
+
onClick={handleLogout}
|
|
2047
|
+
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-400 hover:text-red-400 transition"
|
|
2048
|
+
>
|
|
2049
|
+
<LogOut className="w-4 h-4" /> Logout
|
|
2050
|
+
</button>
|
|
2051
|
+
` : ""} </aside>
|
|
2052
|
+
|
|
2053
|
+
{/* Main content */}
|
|
2054
|
+
<main className="flex-1 p-8 overflow-auto">
|
|
2055
|
+
{page === 'dashboard' && <Dashboard />}
|
|
2056
|
+
{page === 'users' && <UsersPage />}
|
|
2057
|
+
{page === 'posts' && <PostsPage />}
|
|
2058
|
+
</main>
|
|
2059
|
+
</div>
|
|
2060
|
+
);
|
|
1953
2061
|
}
|
|
2062
|
+
`;
|
|
2063
|
+
}
|
|
2064
|
+
function generateLoginPage() {
|
|
2065
|
+
return `import { useState, FormEvent } from 'react';
|
|
2066
|
+
import { Server, Loader2 } from 'lucide-react';
|
|
2067
|
+
import { apiFetch, setToken } from '../lib/api';
|
|
1954
2068
|
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
const res = await fetch(\`\${API_BASE}\${endpoint}\`, {
|
|
1958
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1959
|
-
...options,
|
|
1960
|
-
});
|
|
1961
|
-
const time = Math.round(performance.now() - start);
|
|
1962
|
-
const data = await res.json();
|
|
1963
|
-
return { status: res.status, data, time };
|
|
2069
|
+
interface Props {
|
|
2070
|
+
onLogin: (token: string) => void;
|
|
1964
2071
|
}
|
|
1965
2072
|
|
|
1966
|
-
function
|
|
1967
|
-
const
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2073
|
+
export default function Login({ onLogin }: Props) {
|
|
2074
|
+
const [email, setEmail] = useState('admin@kozo.app');
|
|
2075
|
+
const [password, setPassword] = useState('password123');
|
|
2076
|
+
const [error, setError] = useState('');
|
|
2077
|
+
const [loading, setLoading] = useState(false);
|
|
2078
|
+
|
|
2079
|
+
const submit = async (e: FormEvent) => {
|
|
2080
|
+
e.preventDefault();
|
|
2081
|
+
setError('');
|
|
2082
|
+
setLoading(true);
|
|
2083
|
+
try {
|
|
2084
|
+
const res = await apiFetch<{ token: string }>('/auth/login', {
|
|
2085
|
+
method: 'POST',
|
|
2086
|
+
body: JSON.stringify({ email, password }),
|
|
2087
|
+
});
|
|
2088
|
+
if (res.status === 200 && res.data.token) {
|
|
2089
|
+
setToken(res.data.token);
|
|
2090
|
+
onLogin(res.data.token);
|
|
2091
|
+
} else {
|
|
2092
|
+
setError('Invalid credentials');
|
|
2093
|
+
}
|
|
2094
|
+
} catch {
|
|
2095
|
+
setError('Connection failed \u2014 is the API running?');
|
|
2096
|
+
} finally {
|
|
2097
|
+
setLoading(false);
|
|
2098
|
+
}
|
|
1972
2099
|
};
|
|
1973
|
-
return <span className={\`px-2 py-0.5 rounded text-xs font-medium \${colors[variant]}\`}>{children}</span>;
|
|
1974
|
-
}
|
|
1975
2100
|
|
|
1976
|
-
function Card({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
|
|
1977
2101
|
return (
|
|
1978
|
-
<div className="
|
|
1979
|
-
<div className="
|
|
1980
|
-
<
|
|
1981
|
-
|
|
2102
|
+
<div className="min-h-screen flex items-center justify-center p-4">
|
|
2103
|
+
<div className="w-full max-w-sm">
|
|
2104
|
+
<div className="flex flex-col items-center mb-8">
|
|
2105
|
+
<div className="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center mb-4">
|
|
2106
|
+
<Server className="w-6 h-6 text-white" />
|
|
2107
|
+
</div>
|
|
2108
|
+
<h1 className="text-2xl font-bold text-slate-200">Sign in</h1>
|
|
2109
|
+
<p className="text-slate-400 text-sm mt-1">to your kozo dashboard</p>
|
|
2110
|
+
</div>
|
|
2111
|
+
|
|
2112
|
+
<form onSubmit={submit} className="bg-slate-800 rounded-xl border border-slate-700 p-6 space-y-4">
|
|
2113
|
+
<div>
|
|
2114
|
+
<label className="block text-xs font-medium text-slate-400 mb-1">Email</label>
|
|
2115
|
+
<input
|
|
2116
|
+
type="email"
|
|
2117
|
+
value={email}
|
|
2118
|
+
onChange={e => setEmail(e.target.value)}
|
|
2119
|
+
className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
|
|
2120
|
+
required
|
|
2121
|
+
/>
|
|
2122
|
+
</div>
|
|
2123
|
+
<div>
|
|
2124
|
+
<label className="block text-xs font-medium text-slate-400 mb-1">Password</label>
|
|
2125
|
+
<input
|
|
2126
|
+
type="password"
|
|
2127
|
+
value={password}
|
|
2128
|
+
onChange={e => setPassword(e.target.value)}
|
|
2129
|
+
className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
|
|
2130
|
+
required
|
|
2131
|
+
/>
|
|
2132
|
+
</div>
|
|
2133
|
+
{error && <p className="text-red-400 text-xs">{error}</p>}
|
|
2134
|
+
<button
|
|
2135
|
+
type="submit"
|
|
2136
|
+
disabled={loading}
|
|
2137
|
+
className="w-full py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center justify-center gap-2"
|
|
2138
|
+
>
|
|
2139
|
+
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
2140
|
+
Sign in
|
|
2141
|
+
</button>
|
|
2142
|
+
</form>
|
|
1982
2143
|
</div>
|
|
1983
|
-
<div className="p-4">{children}</div>
|
|
1984
2144
|
</div>
|
|
1985
2145
|
);
|
|
1986
2146
|
}
|
|
2147
|
+
`;
|
|
2148
|
+
}
|
|
2149
|
+
function generateDashboardPage() {
|
|
2150
|
+
return `import { useQuery } from '@tanstack/react-query';
|
|
2151
|
+
import { apiFetch } from '../lib/api';
|
|
2152
|
+
import { Users, FileText, Activity, Zap } from 'lucide-react';
|
|
2153
|
+
|
|
2154
|
+
interface Stats {
|
|
2155
|
+
users?: { total: number; admins?: number; regular?: number };
|
|
2156
|
+
posts?: { total: number; published?: number; drafts?: number };
|
|
2157
|
+
performance?: { uptime: number; memoryUsage?: { heapUsed: number } };
|
|
2158
|
+
// fullstack format
|
|
2159
|
+
timestamp?: string;
|
|
2160
|
+
version?: string;
|
|
2161
|
+
}
|
|
1987
2162
|
|
|
1988
|
-
function
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
const isSuccess = response.status >= 200 && response.status < 300;
|
|
2163
|
+
function StatCard({ label, value, sub, icon: Icon, color }: {
|
|
2164
|
+
label: string; value: string | number; sub?: string;
|
|
2165
|
+
icon: React.ElementType; color: string;
|
|
2166
|
+
}) {
|
|
1993
2167
|
return (
|
|
1994
|
-
<div className="
|
|
1995
|
-
<div className="flex items-
|
|
1996
|
-
<
|
|
1997
|
-
|
|
2168
|
+
<div className="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
|
2169
|
+
<div className="flex items-start justify-between">
|
|
2170
|
+
<div>
|
|
2171
|
+
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{label}</p>
|
|
2172
|
+
<p className="text-3xl font-bold text-slate-100">{value}</p>
|
|
2173
|
+
{sub && <p className="text-xs text-slate-500 mt-1">{sub}</p>}
|
|
2174
|
+
</div>
|
|
2175
|
+
<div className={\`w-10 h-10 rounded-lg flex items-center justify-center \${color}\`}>
|
|
2176
|
+
<Icon className="w-5 h-5 text-white" />
|
|
2177
|
+
</div>
|
|
1998
2178
|
</div>
|
|
1999
|
-
<pre className="text-xs text-slate-300 overflow-auto max-h-48">
|
|
2000
|
-
{JSON.stringify(response.data, null, 2)}
|
|
2001
|
-
</pre>
|
|
2002
2179
|
</div>
|
|
2003
2180
|
);
|
|
2004
2181
|
}
|
|
2005
2182
|
|
|
2006
|
-
function
|
|
2007
|
-
const
|
|
2008
|
-
|
|
2183
|
+
export default function Dashboard() {
|
|
2184
|
+
const { data: health } = useQuery({
|
|
2185
|
+
queryKey: ['health'],
|
|
2186
|
+
queryFn: () => apiFetch<{ status: string; uptime: number; version: string }>('/health').then(r => r.data),
|
|
2187
|
+
});
|
|
2009
2188
|
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
setLoading(false);
|
|
2015
|
-
};
|
|
2189
|
+
const { data: stats, isLoading } = useQuery({
|
|
2190
|
+
queryKey: ['stats'],
|
|
2191
|
+
queryFn: () => apiFetch<Stats>('/stats').then(r => r.data),
|
|
2192
|
+
});
|
|
2016
2193
|
|
|
2017
|
-
const
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2194
|
+
const uptime = health?.uptime ?? stats?.performance?.uptime;
|
|
2195
|
+
const uptimeStr = uptime !== undefined
|
|
2196
|
+
? uptime > 3600 ? \`\${Math.floor(uptime / 3600)}h \${Math.floor((uptime % 3600) / 60)}m\`
|
|
2197
|
+
: uptime > 60 ? \`\${Math.floor(uptime / 60)}m \${Math.floor(uptime % 60)}s\`
|
|
2198
|
+
: \`\${Math.floor(uptime)}s\`
|
|
2199
|
+
: '\u2014';
|
|
2023
2200
|
|
|
2024
2201
|
return (
|
|
2025
|
-
<
|
|
2026
|
-
<div className="
|
|
2027
|
-
<
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2202
|
+
<div>
|
|
2203
|
+
<div className="mb-6">
|
|
2204
|
+
<h1 className="text-2xl font-bold text-slate-100">Dashboard</h1>
|
|
2205
|
+
<p className="text-slate-400 text-sm mt-1">Server overview and statistics</p>
|
|
2206
|
+
</div>
|
|
2207
|
+
|
|
2208
|
+
{isLoading ? (
|
|
2209
|
+
<div className="text-slate-400">Loading stats...</div>
|
|
2210
|
+
) : (
|
|
2211
|
+
<div className="grid grid-cols-2 gap-4 mb-8">
|
|
2212
|
+
<StatCard
|
|
2213
|
+
label="Users"
|
|
2214
|
+
value={stats?.users?.total ?? '\u2014'}
|
|
2215
|
+
sub={stats?.users ? \`\${stats.users.admins ?? 0} admins \xB7 \${stats.users.regular ?? 0} regular\` : undefined}
|
|
2216
|
+
icon={Users} color="bg-blue-600"
|
|
2217
|
+
/>
|
|
2218
|
+
<StatCard
|
|
2219
|
+
label="Posts"
|
|
2220
|
+
value={stats?.posts?.total ?? '\u2014'}
|
|
2221
|
+
sub={stats?.posts ? \`\${stats.posts.published ?? 0} published \xB7 \${stats.posts.drafts ?? 0} drafts\` : undefined}
|
|
2222
|
+
icon={FileText} color="bg-purple-600"
|
|
2223
|
+
/>
|
|
2224
|
+
<StatCard
|
|
2225
|
+
label="Uptime"
|
|
2226
|
+
value={uptimeStr}
|
|
2227
|
+
icon={Activity} color="bg-emerald-600"
|
|
2228
|
+
/>
|
|
2229
|
+
<StatCard
|
|
2230
|
+
label="API Status"
|
|
2231
|
+
value={health?.status === 'ok' ? '\u2713 Online' : '? Unknown'}
|
|
2232
|
+
sub={health?.version ? \`v\${health.version}\` : undefined}
|
|
2233
|
+
icon={Zap} color="bg-amber-600"
|
|
2234
|
+
/>
|
|
2235
|
+
</div>
|
|
2236
|
+
)}
|
|
2237
|
+
|
|
2238
|
+
<div className="bg-slate-800 rounded-xl border border-slate-700 p-4">
|
|
2239
|
+
<h3 className="text-sm font-medium text-slate-300 mb-3">Quick API Test</h3>
|
|
2240
|
+
<div className="flex items-center gap-2">
|
|
2241
|
+
<div className="flex-1 px-3 py-2 bg-slate-900 rounded-lg text-sm text-slate-400 font-mono">
|
|
2242
|
+
GET /health
|
|
2243
|
+
</div>
|
|
2244
|
+
<span className={\`px-2 py-0.5 rounded text-xs font-medium \${
|
|
2245
|
+
health?.status === 'ok' ? 'bg-emerald-900 text-emerald-300' : 'bg-slate-700 text-slate-400'
|
|
2246
|
+
}\`}>
|
|
2247
|
+
{health?.status ?? 'pending'}
|
|
2248
|
+
</span>
|
|
2249
|
+
</div>
|
|
2033
2250
|
</div>
|
|
2034
|
-
|
|
2035
|
-
</Card>
|
|
2251
|
+
</div>
|
|
2036
2252
|
);
|
|
2037
2253
|
}
|
|
2254
|
+
`;
|
|
2255
|
+
}
|
|
2256
|
+
function generateUsersPage() {
|
|
2257
|
+
return `import { useState } from 'react';
|
|
2258
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2259
|
+
import { apiFetch } from '../lib/api';
|
|
2260
|
+
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
|
2261
|
+
|
|
2262
|
+
interface User {
|
|
2263
|
+
id: string | number;
|
|
2264
|
+
name: string;
|
|
2265
|
+
email: string;
|
|
2266
|
+
role?: string;
|
|
2267
|
+
createdAt?: string;
|
|
2268
|
+
}
|
|
2038
2269
|
|
|
2039
|
-
function
|
|
2270
|
+
export default function UsersPage() {
|
|
2040
2271
|
const queryClient = useQueryClient();
|
|
2041
|
-
const [
|
|
2272
|
+
const [form, setForm] = useState({ name: '', email: '' });
|
|
2042
2273
|
const [loading, setLoading] = useState(false);
|
|
2043
|
-
const [
|
|
2274
|
+
const [error, setError] = useState('');
|
|
2044
2275
|
|
|
2045
|
-
const { data: users, isLoading } = useQuery({
|
|
2276
|
+
const { data: users = [], isLoading } = useQuery({
|
|
2046
2277
|
queryKey: ['users'],
|
|
2047
2278
|
queryFn: async () => {
|
|
2048
|
-
const res = await
|
|
2049
|
-
return res.data
|
|
2279
|
+
const res = await apiFetch<User[] | { users: User[] }>('/users');
|
|
2280
|
+
return Array.isArray(res.data) ? res.data : res.data.users ?? [];
|
|
2050
2281
|
},
|
|
2051
2282
|
});
|
|
2052
2283
|
|
|
2053
2284
|
const createUser = async () => {
|
|
2054
|
-
if (!
|
|
2055
|
-
setLoading(true);
|
|
2056
|
-
|
|
2057
|
-
method: 'POST',
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
2285
|
+
if (!form.name || !form.email) { setError('Name and email required'); return; }
|
|
2286
|
+
setLoading(true); setError('');
|
|
2287
|
+
try {
|
|
2288
|
+
const res = await apiFetch('/users', { method: 'POST', body: JSON.stringify(form) });
|
|
2289
|
+
if (res.status >= 400) throw new Error('Failed');
|
|
2290
|
+
setForm({ name: '', email: '' });
|
|
2291
|
+
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
2292
|
+
} catch { setError('Failed to create user'); }
|
|
2293
|
+
finally { setLoading(false); }
|
|
2064
2294
|
};
|
|
2065
2295
|
|
|
2066
|
-
const deleteUser = async (id: string) => {
|
|
2296
|
+
const deleteUser = async (id: string | number) => {
|
|
2067
2297
|
setLoading(true);
|
|
2068
|
-
|
|
2069
|
-
setResponse(res);
|
|
2070
|
-
setLoading(false);
|
|
2298
|
+
await apiFetch(\`/users/\${id}\`, { method: 'DELETE' });
|
|
2071
2299
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
2300
|
+
setLoading(false);
|
|
2072
2301
|
};
|
|
2073
2302
|
|
|
2074
2303
|
return (
|
|
2075
|
-
<
|
|
2076
|
-
<div className="
|
|
2077
|
-
<
|
|
2304
|
+
<div>
|
|
2305
|
+
<div className="mb-6">
|
|
2306
|
+
<h1 className="text-2xl font-bold text-slate-100">Users</h1>
|
|
2307
|
+
<p className="text-slate-400 text-sm mt-1">{users.length} total</p>
|
|
2308
|
+
</div>
|
|
2309
|
+
|
|
2310
|
+
{/* Create form */}
|
|
2311
|
+
<div className="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
|
|
2312
|
+
<h3 className="text-sm font-medium text-slate-300 mb-3">Add User</h3>
|
|
2313
|
+
<div className="flex gap-3">
|
|
2078
2314
|
<input
|
|
2079
|
-
placeholder="
|
|
2080
|
-
value={
|
|
2081
|
-
onChange={
|
|
2082
|
-
className="px-3 py-
|
|
2315
|
+
placeholder="Full name"
|
|
2316
|
+
value={form.name}
|
|
2317
|
+
onChange={e => setForm({ ...form, name: e.target.value })}
|
|
2318
|
+
className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
|
|
2083
2319
|
/>
|
|
2084
2320
|
<input
|
|
2085
|
-
placeholder="
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2321
|
+
placeholder="email@example.com"
|
|
2322
|
+
type="email"
|
|
2323
|
+
value={form.email}
|
|
2324
|
+
onChange={e => setForm({ ...form, email: e.target.value })}
|
|
2325
|
+
className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
|
|
2089
2326
|
/>
|
|
2090
|
-
<button
|
|
2091
|
-
|
|
2327
|
+
<button
|
|
2328
|
+
onClick={createUser}
|
|
2329
|
+
disabled={loading}
|
|
2330
|
+
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center gap-2"
|
|
2331
|
+
>
|
|
2332
|
+
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|
2333
|
+
Add
|
|
2092
2334
|
</button>
|
|
2093
2335
|
</div>
|
|
2336
|
+
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
|
2337
|
+
</div>
|
|
2094
2338
|
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
</
|
|
2106
|
-
<
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
</
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2339
|
+
{/* Users table */}
|
|
2340
|
+
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
|
2341
|
+
{isLoading ? (
|
|
2342
|
+
<div className="p-8 text-center text-slate-400 text-sm">Loading users...</div>
|
|
2343
|
+
) : users.length === 0 ? (
|
|
2344
|
+
<div className="p-8 text-center text-slate-400 text-sm">No users yet. Add one above.</div>
|
|
2345
|
+
) : (
|
|
2346
|
+
<table className="w-full">
|
|
2347
|
+
<thead>
|
|
2348
|
+
<tr className="border-b border-slate-700">
|
|
2349
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase">Name</th>
|
|
2350
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase">Email</th>
|
|
2351
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase">Role</th>
|
|
2352
|
+
<th className="px-4 py-3"></th>
|
|
2353
|
+
</tr>
|
|
2354
|
+
</thead>
|
|
2355
|
+
<tbody>
|
|
2356
|
+
{users.map((user) => (
|
|
2357
|
+
<tr key={user.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
|
|
2358
|
+
<td className="px-4 py-3 text-sm font-medium text-slate-200">{user.name}</td>
|
|
2359
|
+
<td className="px-4 py-3 text-sm text-slate-400">{user.email}</td>
|
|
2360
|
+
<td className="px-4 py-3">
|
|
2361
|
+
{user.role && (
|
|
2362
|
+
<span className={\`px-2 py-0.5 rounded-full text-xs font-medium \${
|
|
2363
|
+
user.role === 'admin' ? 'bg-amber-900 text-amber-300' : 'bg-slate-700 text-slate-300'
|
|
2364
|
+
}\`}>
|
|
2365
|
+
{user.role}
|
|
2366
|
+
</span>
|
|
2367
|
+
)}
|
|
2368
|
+
</td>
|
|
2369
|
+
<td className="px-4 py-3 text-right">
|
|
2370
|
+
<button
|
|
2371
|
+
onClick={() => deleteUser(user.id)}
|
|
2372
|
+
className="p-1.5 text-slate-500 hover:text-red-400 transition rounded"
|
|
2373
|
+
>
|
|
2374
|
+
<Trash2 className="w-4 h-4" />
|
|
2375
|
+
</button>
|
|
2376
|
+
</td>
|
|
2377
|
+
</tr>
|
|
2378
|
+
))}
|
|
2379
|
+
</tbody>
|
|
2380
|
+
</table>
|
|
2381
|
+
)}
|
|
2115
2382
|
</div>
|
|
2116
|
-
</
|
|
2383
|
+
</div>
|
|
2117
2384
|
);
|
|
2118
2385
|
}
|
|
2386
|
+
`;
|
|
2387
|
+
}
|
|
2388
|
+
function generatePostsPage() {
|
|
2389
|
+
return `import { useState } from 'react';
|
|
2390
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2391
|
+
import { apiFetch } from '../lib/api';
|
|
2392
|
+
import { Plus, Trash2, Loader2, Globe, Lock } from 'lucide-react';
|
|
2393
|
+
|
|
2394
|
+
interface Post {
|
|
2395
|
+
id: string | number;
|
|
2396
|
+
title: string;
|
|
2397
|
+
content?: string;
|
|
2398
|
+
published?: boolean;
|
|
2399
|
+
authorId?: string | number;
|
|
2400
|
+
tags?: string[];
|
|
2401
|
+
createdAt?: string;
|
|
2402
|
+
}
|
|
2119
2403
|
|
|
2120
|
-
function
|
|
2404
|
+
export default function PostsPage() {
|
|
2121
2405
|
const queryClient = useQueryClient();
|
|
2122
|
-
const [
|
|
2406
|
+
const [form, setForm] = useState({ title: '', content: '', published: false });
|
|
2123
2407
|
const [loading, setLoading] = useState(false);
|
|
2124
|
-
const [
|
|
2408
|
+
const [error, setError] = useState('');
|
|
2125
2409
|
|
|
2126
|
-
const { data:
|
|
2127
|
-
queryKey: ['
|
|
2410
|
+
const { data: posts = [], isLoading } = useQuery({
|
|
2411
|
+
queryKey: ['posts'],
|
|
2128
2412
|
queryFn: async () => {
|
|
2129
|
-
const res = await
|
|
2130
|
-
return res.data
|
|
2413
|
+
const res = await apiFetch<Post[] | { posts: Post[] }>('/posts');
|
|
2414
|
+
return Array.isArray(res.data) ? res.data : res.data.posts ?? [];
|
|
2131
2415
|
},
|
|
2132
2416
|
});
|
|
2133
2417
|
|
|
2134
|
-
const
|
|
2135
|
-
if (!
|
|
2136
|
-
setLoading(true);
|
|
2137
|
-
|
|
2138
|
-
method: 'POST',
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2145
|
-
};
|
|
2146
|
-
|
|
2147
|
-
const toggleTask = async (id: string) => {
|
|
2148
|
-
setLoading(true);
|
|
2149
|
-
const res = await fetchApi(\`/tasks/\${id}/toggle\`, { method: 'PATCH' });
|
|
2150
|
-
setResponse(res);
|
|
2151
|
-
setLoading(false);
|
|
2152
|
-
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2418
|
+
const createPost = async () => {
|
|
2419
|
+
if (!form.title) { setError('Title required'); return; }
|
|
2420
|
+
setLoading(true); setError('');
|
|
2421
|
+
try {
|
|
2422
|
+
const res = await apiFetch('/posts', { method: 'POST', body: JSON.stringify(form) });
|
|
2423
|
+
if (res.status >= 400) throw new Error('Failed');
|
|
2424
|
+
setForm({ title: '', content: '', published: false });
|
|
2425
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
2426
|
+
} catch { setError('Failed to create post'); }
|
|
2427
|
+
finally { setLoading(false); }
|
|
2153
2428
|
};
|
|
2154
2429
|
|
|
2155
|
-
const
|
|
2430
|
+
const deletePost = async (id: string | number) => {
|
|
2156
2431
|
setLoading(true);
|
|
2157
|
-
|
|
2158
|
-
|
|
2432
|
+
await apiFetch(\`/posts/\${id}\`, { method: 'DELETE' });
|
|
2433
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
2159
2434
|
setLoading(false);
|
|
2160
|
-
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2161
|
-
};
|
|
2162
|
-
|
|
2163
|
-
const priorityColor = (p: string) => {
|
|
2164
|
-
switch (p) {
|
|
2165
|
-
case 'high': return 'error';
|
|
2166
|
-
case 'medium': return 'warning';
|
|
2167
|
-
default: return 'default';
|
|
2168
|
-
}
|
|
2169
2435
|
};
|
|
2170
2436
|
|
|
2171
2437
|
return (
|
|
2172
|
-
<
|
|
2173
|
-
<div className="
|
|
2174
|
-
<
|
|
2175
|
-
|
|
2176
|
-
placeholder="Task title"
|
|
2177
|
-
value={newTask.title}
|
|
2178
|
-
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
|
2179
|
-
className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
2180
|
-
/>
|
|
2181
|
-
<select
|
|
2182
|
-
value={newTask.priority}
|
|
2183
|
-
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value as 'low' | 'medium' | 'high' })}
|
|
2184
|
-
className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
2185
|
-
>
|
|
2186
|
-
<option value="low">Low</option>
|
|
2187
|
-
<option value="medium">Medium</option>
|
|
2188
|
-
<option value="high">High</option>
|
|
2189
|
-
</select>
|
|
2190
|
-
<button onClick={createTask} disabled={loading} className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded text-sm font-medium transition flex items-center justify-center gap-1">
|
|
2191
|
-
<Plus className="w-4 h-4" /> Add Task
|
|
2192
|
-
</button>
|
|
2193
|
-
</div>
|
|
2194
|
-
|
|
2195
|
-
<div className="space-y-2">
|
|
2196
|
-
{isLoading ? (
|
|
2197
|
-
<div className="text-slate-400 text-sm">Loading tasks...</div>
|
|
2198
|
-
) : (
|
|
2199
|
-
tasks?.map((task) => (
|
|
2200
|
-
<div key={task.id} className="flex items-center justify-between p-2 bg-slate-900 rounded border border-slate-700">
|
|
2201
|
-
<div className="flex items-center gap-2">
|
|
2202
|
-
<input
|
|
2203
|
-
type="checkbox"
|
|
2204
|
-
checked={task.completed}
|
|
2205
|
-
onChange={() => toggleTask(task.id)}
|
|
2206
|
-
className="rounded"
|
|
2207
|
-
/>
|
|
2208
|
-
<span className={task.completed ? 'line-through text-slate-500' : ''}>{task.title}</span>
|
|
2209
|
-
<Badge variant={priorityColor(task.priority) as 'default' | 'success' | 'warning' | 'error'}>{task.priority}</Badge>
|
|
2210
|
-
</div>
|
|
2211
|
-
<button onClick={() => deleteTask(task.id)} className="p-1 text-red-400 hover:text-red-300 transition">
|
|
2212
|
-
<Trash2 className="w-4 h-4" />
|
|
2213
|
-
</button>
|
|
2214
|
-
</div>
|
|
2215
|
-
))
|
|
2216
|
-
)}
|
|
2217
|
-
</div>
|
|
2218
|
-
|
|
2219
|
-
<ResponseDisplay response={response} loading={loading} />
|
|
2438
|
+
<div>
|
|
2439
|
+
<div className="mb-6">
|
|
2440
|
+
<h1 className="text-2xl font-bold text-slate-100">Posts</h1>
|
|
2441
|
+
<p className="text-slate-400 text-sm mt-1">{posts.length} total</p>
|
|
2220
2442
|
</div>
|
|
2221
|
-
</Card>
|
|
2222
|
-
);
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
function ToolsPanel() {
|
|
2226
|
-
const [echoMessage, setEchoMessage] = useState('');
|
|
2227
|
-
const [validateData, setValidateData] = useState({ email: '', age: '' });
|
|
2228
|
-
const [response, setResponse] = useState<ApiResponse | null>(null);
|
|
2229
|
-
const [loading, setLoading] = useState(false);
|
|
2230
2443
|
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
<
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
<Send className="w-4 h-4" /> Echo
|
|
2266
|
-
</button>
|
|
2267
|
-
</div>
|
|
2268
|
-
</div>
|
|
2269
|
-
|
|
2270
|
-
<div>
|
|
2271
|
-
<h4 className="text-sm font-medium text-slate-300 mb-2">Validation Endpoint</h4>
|
|
2272
|
-
<div className="flex gap-2">
|
|
2273
|
-
<input
|
|
2274
|
-
placeholder="Email"
|
|
2275
|
-
value={validateData.email}
|
|
2276
|
-
onChange={(e) => setValidateData({ ...validateData, email: e.target.value })}
|
|
2277
|
-
className="flex-1 px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
2278
|
-
/>
|
|
2279
|
-
<input
|
|
2280
|
-
placeholder="Age"
|
|
2281
|
-
type="number"
|
|
2282
|
-
value={validateData.age}
|
|
2283
|
-
onChange={(e) => setValidateData({ ...validateData, age: e.target.value })}
|
|
2284
|
-
className="w-24 px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
2285
|
-
/>
|
|
2286
|
-
<button onClick={testValidate} className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-medium transition flex items-center gap-1">
|
|
2287
|
-
<CheckSquare className="w-4 h-4" /> Validate
|
|
2444
|
+
{/* Create form */}
|
|
2445
|
+
<div className="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
|
|
2446
|
+
<h3 className="text-sm font-medium text-slate-300 mb-3">New Post</h3>
|
|
2447
|
+
<div className="space-y-3">
|
|
2448
|
+
<input
|
|
2449
|
+
placeholder="Post title"
|
|
2450
|
+
value={form.title}
|
|
2451
|
+
onChange={e => setForm({ ...form, title: e.target.value })}
|
|
2452
|
+
className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
|
|
2453
|
+
/>
|
|
2454
|
+
<textarea
|
|
2455
|
+
placeholder="Content (optional)"
|
|
2456
|
+
value={form.content}
|
|
2457
|
+
onChange={e => setForm({ ...form, content: e.target.value })}
|
|
2458
|
+
rows={2}
|
|
2459
|
+
className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none resize-none"
|
|
2460
|
+
/>
|
|
2461
|
+
<div className="flex items-center justify-between">
|
|
2462
|
+
<label className="flex items-center gap-2 text-sm text-slate-400 cursor-pointer">
|
|
2463
|
+
<input
|
|
2464
|
+
type="checkbox"
|
|
2465
|
+
checked={form.published}
|
|
2466
|
+
onChange={e => setForm({ ...form, published: e.target.checked })}
|
|
2467
|
+
className="rounded"
|
|
2468
|
+
/>
|
|
2469
|
+
Publish immediately
|
|
2470
|
+
</label>
|
|
2471
|
+
<button
|
|
2472
|
+
onClick={createPost}
|
|
2473
|
+
disabled={loading}
|
|
2474
|
+
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center gap-2"
|
|
2475
|
+
>
|
|
2476
|
+
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|
2477
|
+
Create
|
|
2288
2478
|
</button>
|
|
2289
2479
|
</div>
|
|
2290
2480
|
</div>
|
|
2291
|
-
|
|
2292
|
-
<ResponseDisplay response={response} loading={loading} />
|
|
2481
|
+
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
|
2293
2482
|
</div>
|
|
2294
|
-
</Card>
|
|
2295
|
-
);
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
export default function App() {
|
|
2299
|
-
const [activeTab, setActiveTab] = useState<Tab>('health');
|
|
2300
|
-
|
|
2301
|
-
const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [
|
|
2302
|
-
{ id: 'health', label: 'Health', icon: Activity },
|
|
2303
|
-
{ id: 'users', label: 'Users', icon: Users },
|
|
2304
|
-
{ id: 'tasks', label: 'Tasks', icon: CheckSquare },
|
|
2305
|
-
{ id: 'tools', label: 'Tools', icon: Zap },
|
|
2306
|
-
];
|
|
2307
|
-
|
|
2308
|
-
return (
|
|
2309
|
-
<div className="min-h-screen p-6">
|
|
2310
|
-
<div className="max-w-4xl mx-auto">
|
|
2311
|
-
<header className="mb-8">
|
|
2312
|
-
<div className="flex items-center gap-3 mb-2">
|
|
2313
|
-
<Server className="w-8 h-8 text-blue-400" />
|
|
2314
|
-
<h1 className="text-3xl font-bold">Kozo API Tester</h1>
|
|
2315
|
-
</div>
|
|
2316
|
-
<p className="text-slate-400">Test the ${projectName} API endpoints with this interactive UI</p>
|
|
2317
|
-
</header>
|
|
2318
|
-
|
|
2319
|
-
<nav className="flex gap-1 mb-6 bg-slate-800 p-1 rounded-lg">
|
|
2320
|
-
{tabs.map(({ id, label, icon: Icon }) => (
|
|
2321
|
-
<button
|
|
2322
|
-
key={id}
|
|
2323
|
-
onClick={() => setActiveTab(id)}
|
|
2324
|
-
className={\`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition \${
|
|
2325
|
-
activeTab === id
|
|
2326
|
-
? 'bg-blue-600 text-white'
|
|
2327
|
-
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
|
2328
|
-
}\`}
|
|
2329
|
-
>
|
|
2330
|
-
<Icon className="w-4 h-4" />
|
|
2331
|
-
{label}
|
|
2332
|
-
</button>
|
|
2333
|
-
))}
|
|
2334
|
-
</nav>
|
|
2335
2483
|
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2484
|
+
{/* Posts list */}
|
|
2485
|
+
<div className="space-y-3">
|
|
2486
|
+
{isLoading ? (
|
|
2487
|
+
<div className="text-center text-slate-400 text-sm py-8">Loading posts...</div>
|
|
2488
|
+
) : posts.length === 0 ? (
|
|
2489
|
+
<div className="text-center text-slate-400 text-sm py-8">No posts yet. Create one above.</div>
|
|
2490
|
+
) : (
|
|
2491
|
+
posts.map((post) => (
|
|
2492
|
+
<div key={post.id} className="bg-slate-800 rounded-xl border border-slate-700 p-4 flex items-start justify-between">
|
|
2493
|
+
<div className="flex-1 min-w-0">
|
|
2494
|
+
<div className="flex items-center gap-2 mb-1">
|
|
2495
|
+
{post.published
|
|
2496
|
+
? <Globe className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0" />
|
|
2497
|
+
: <Lock className="w-3.5 h-3.5 text-slate-500 flex-shrink-0" />
|
|
2498
|
+
}
|
|
2499
|
+
<h3 className="font-medium text-slate-200 truncate">{post.title}</h3>
|
|
2500
|
+
</div>
|
|
2501
|
+
{post.content && (
|
|
2502
|
+
<p className="text-sm text-slate-400 line-clamp-2">{post.content}</p>
|
|
2503
|
+
)}
|
|
2504
|
+
{post.tags && post.tags.length > 0 && (
|
|
2505
|
+
<div className="flex gap-1.5 mt-2">
|
|
2506
|
+
{post.tags.map(tag => (
|
|
2507
|
+
<span key={tag} className="px-2 py-0.5 bg-slate-700 rounded text-xs text-slate-300">{tag}</span>
|
|
2508
|
+
))}
|
|
2509
|
+
</div>
|
|
2510
|
+
)}
|
|
2511
|
+
</div>
|
|
2512
|
+
<button
|
|
2513
|
+
onClick={() => deletePost(post.id)}
|
|
2514
|
+
className="ml-3 p-1.5 text-slate-500 hover:text-red-400 transition rounded flex-shrink-0"
|
|
2515
|
+
>
|
|
2516
|
+
<Trash2 className="w-4 h-4" />
|
|
2517
|
+
</button>
|
|
2518
|
+
</div>
|
|
2519
|
+
))
|
|
2520
|
+
)}
|
|
2346
2521
|
</div>
|
|
2347
2522
|
</div>
|
|
2348
2523
|
);
|