@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.
Files changed (2) hide show
  1. package/lib/index.js +536 -361
  2. 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
- if (!hasDb) await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "data"));
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
- background-color: rgb(15 23 42);
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", "client.ts"), `import { hc } from 'hono/client';
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
- // Type-safe RPC client - changes in API break frontend at compile time!
1905
- export const client = hc<AppType>('/');
1923
+ ${auth ? `export function getToken(): string | null {
1924
+ return localStorage.getItem('kozo_token');
1925
+ }
1906
1926
 
1907
- /* Usage:
1908
- const res = await client.api.users.$get();
1909
- const users = await res.json(); // Fully typed!
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", "App.tsx"), generateFullReactApp(projectName));
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 generateFullReactApp(projectName) {
1938
- return `import { useState } from 'react';
1939
- import { useQuery, useQueryClient } from '@tanstack/react-query';
1940
- import {
1941
- Activity, Users, FileText, CheckSquare, Send, Trash2, Edit2,
1942
- Plus, Server, Zap, Heart
1943
- } from 'lucide-react';
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
- const API_BASE = '/api';
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
- type Tab = 'health' | 'users' | 'posts' | 'tasks' | 'tools';
2003
+ useEffect(() => { if (token) { /* token refreshed */ } }, [token]);
1948
2004
 
1949
- interface ApiResponse {
1950
- status: number;
1951
- data: unknown;
1952
- time: number;
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
- async function fetchApi(endpoint: string, options?: RequestInit): Promise<ApiResponse> {
1956
- const start = performance.now();
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 Badge({ children, variant = 'default' }: { children: React.ReactNode; variant?: 'default' | 'success' | 'warning' | 'error' }) {
1967
- const colors = {
1968
- default: 'bg-slate-700 text-slate-200',
1969
- success: 'bg-emerald-900 text-emerald-300',
1970
- warning: 'bg-amber-900 text-amber-300',
1971
- error: 'bg-red-900 text-red-300',
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="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
1979
- <div className="px-4 py-3 border-b border-slate-700 flex items-center gap-2">
1980
- <Icon className="w-4 h-4 text-blue-400" />
1981
- <h3 className="font-medium text-slate-200">{title}</h3>
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 ResponseDisplay({ response, loading }: { response: ApiResponse | null; loading: boolean }) {
1989
- if (loading) return <div className="text-slate-400 text-sm">Loading...</div>;
1990
- if (!response) return null;
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="mt-3 p-3 bg-slate-900 rounded border border-slate-700">
1995
- <div className="flex items-center gap-2 mb-2">
1996
- <Badge variant={isSuccess ? 'success' : 'error'}>{response.status}</Badge>
1997
- <span className="text-xs text-slate-500">{response.time}ms</span>
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 HealthPanel() {
2007
- const [response, setResponse] = useState<ApiResponse | null>(null);
2008
- const [loading, setLoading] = useState(false);
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 checkHealth = async () => {
2011
- setLoading(true);
2012
- const res = await fetchApi('/health');
2013
- setResponse(res);
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 checkStats = async () => {
2018
- setLoading(true);
2019
- const res = await fetchApi('/stats');
2020
- setResponse(res);
2021
- setLoading(false);
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
- <Card title="Health & Stats" icon={Heart}>
2026
- <div className="flex gap-2 mb-3">
2027
- <button onClick={checkHealth} className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded text-sm font-medium transition">
2028
- Check Health
2029
- </button>
2030
- <button onClick={checkStats} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition">
2031
- Get Stats
2032
- </button>
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
- <ResponseDisplay response={response} loading={loading} />
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 UsersPanel() {
2270
+ export default function UsersPage() {
2040
2271
  const queryClient = useQueryClient();
2041
- const [response, setResponse] = useState<ApiResponse | null>(null);
2272
+ const [form, setForm] = useState({ name: '', email: '' });
2042
2273
  const [loading, setLoading] = useState(false);
2043
- const [newUser, setNewUser] = useState({ name: '', email: '', role: 'user' as 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 fetchApi('/users');
2049
- return res.data as Array<{ id: string; name: string; email: string; role: string }>;
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 (!newUser.name || !newUser.email) return;
2055
- setLoading(true);
2056
- const res = await fetchApi('/users', {
2057
- method: 'POST',
2058
- body: JSON.stringify(newUser),
2059
- });
2060
- setResponse(res);
2061
- setLoading(false);
2062
- setNewUser({ name: '', email: '', role: 'user' });
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
- const res = await fetchApi(\`/users/\${id}\`, { method: 'DELETE' });
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
- <Card title="Users" icon={Users}>
2076
- <div className="space-y-4">
2077
- <div className="grid grid-cols-3 gap-2">
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="Name"
2080
- value={newUser.name}
2081
- onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
2082
- className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
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="Email"
2086
- value={newUser.email}
2087
- onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
2088
- className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
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 onClick={createUser} 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">
2091
- <Plus className="w-4 h-4" /> Add User
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
- <div className="space-y-2">
2096
- {isLoading ? (
2097
- <div className="text-slate-400 text-sm">Loading users...</div>
2098
- ) : (
2099
- users?.map((user) => (
2100
- <div key={user.id} className="flex items-center justify-between p-2 bg-slate-900 rounded border border-slate-700">
2101
- <div>
2102
- <span className="font-medium">{user.name}</span>
2103
- <span className="text-slate-400 text-sm ml-2">{user.email}</span>
2104
- <Badge variant={user.role === 'admin' ? 'warning' : 'default'}>{user.role}</Badge>
2105
- </div>
2106
- <button onClick={() => deleteUser(user.id)} className="p-1 text-red-400 hover:text-red-300 transition">
2107
- <Trash2 className="w-4 h-4" />
2108
- </button>
2109
- </div>
2110
- ))
2111
- )}
2112
- </div>
2113
-
2114
- <ResponseDisplay response={response} loading={loading} />
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
- </Card>
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 TasksPanel() {
2404
+ export default function PostsPage() {
2121
2405
  const queryClient = useQueryClient();
2122
- const [response, setResponse] = useState<ApiResponse | null>(null);
2406
+ const [form, setForm] = useState({ title: '', content: '', published: false });
2123
2407
  const [loading, setLoading] = useState(false);
2124
- const [newTask, setNewTask] = useState({ title: '', priority: 'medium' as const });
2408
+ const [error, setError] = useState('');
2125
2409
 
2126
- const { data: tasks, isLoading } = useQuery({
2127
- queryKey: ['tasks'],
2410
+ const { data: posts = [], isLoading } = useQuery({
2411
+ queryKey: ['posts'],
2128
2412
  queryFn: async () => {
2129
- const res = await fetchApi('/tasks');
2130
- return res.data as Array<{ id: string; title: string; completed: boolean; priority: string }>;
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 createTask = async () => {
2135
- if (!newTask.title) return;
2136
- setLoading(true);
2137
- const res = await fetchApi('/tasks', {
2138
- method: 'POST',
2139
- body: JSON.stringify(newTask),
2140
- });
2141
- setResponse(res);
2142
- setLoading(false);
2143
- setNewTask({ title: '', priority: 'medium' });
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 deleteTask = async (id: string) => {
2430
+ const deletePost = async (id: string | number) => {
2156
2431
  setLoading(true);
2157
- const res = await fetchApi(\`/tasks/\${id}\`, { method: 'DELETE' });
2158
- setResponse(res);
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
- <Card title="Tasks" icon={CheckSquare}>
2173
- <div className="space-y-4">
2174
- <div className="grid grid-cols-3 gap-2">
2175
- <input
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
- const testEcho = async () => {
2232
- if (!echoMessage) return;
2233
- setLoading(true);
2234
- const res = await fetchApi(\`/echo?message=\${encodeURIComponent(echoMessage)}\`);
2235
- setResponse(res);
2236
- setLoading(false);
2237
- };
2238
-
2239
- const testValidate = async () => {
2240
- setLoading(true);
2241
- const res = await fetchApi('/validate', {
2242
- method: 'POST',
2243
- body: JSON.stringify({
2244
- email: validateData.email,
2245
- age: parseInt(validateData.age) || 0,
2246
- }),
2247
- });
2248
- setResponse(res);
2249
- setLoading(false);
2250
- };
2251
-
2252
- return (
2253
- <Card title="Test Tools" icon={Zap}>
2254
- <div className="space-y-6">
2255
- <div>
2256
- <h4 className="text-sm font-medium text-slate-300 mb-2">Echo Endpoint</h4>
2257
- <div className="flex gap-2">
2258
- <input
2259
- placeholder="Message to echo"
2260
- value={echoMessage}
2261
- onChange={(e) => setEchoMessage(e.target.value)}
2262
- className="flex-1 px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
2263
- />
2264
- <button onClick={testEcho} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition flex items-center gap-1">
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
- <main>
2337
- {activeTab === 'health' && <HealthPanel />}
2338
- {activeTab === 'users' && <UsersPanel />}
2339
- {activeTab === 'tasks' && <TasksPanel />}
2340
- {activeTab === 'tools' && <ToolsPanel />}
2341
- </main>
2342
-
2343
- <footer className="mt-8 pt-6 border-t border-slate-800 text-center text-slate-500 text-sm">
2344
- Built with Kozo + React + TailwindCSS
2345
- </footer>
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
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kozojs/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "CLI to scaffold new Kozo Framework projects - The next-gen TypeScript Backend Framework",
5
5
  "bin": {
6
6
  "create-kozo": "./lib/index.js",