@kozojs/cli 0.1.16 → 0.1.18
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 +226 -50
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1495,20 +1495,19 @@ ${auth ? "JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars\n" : ""}`;
|
|
|
1495
1495
|
exclude: ["node_modules", "dist"]
|
|
1496
1496
|
};
|
|
1497
1497
|
await import_fs_extra.default.writeJSON(import_node_path.default.join(apiDir, "tsconfig.json"), tsconfig, { spaces: 2 });
|
|
1498
|
-
const dbImport = hasDb ? `import { createDb } from './db/index.js';
|
|
1499
|
-
` : "";
|
|
1500
1498
|
const authImport = auth ? `import { authenticateJWT } from '@kozojs/auth';
|
|
1501
1499
|
` : "";
|
|
1502
|
-
const servicesSetup =
|
|
1503
|
-
const db = await createDb();
|
|
1504
|
-
const services = { db };
|
|
1505
|
-
` : "\nconst services = {};\n";
|
|
1500
|
+
const servicesSetup = "\nconst services = {};\n";
|
|
1506
1501
|
const authMiddleware = auth ? `
|
|
1507
|
-
|
|
1502
|
+
// Protect resource routes with JWT (auth login route remains public)
|
|
1503
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
|
|
1504
|
+
app.use('*', authenticateJWT(JWT_SECRET, { prefix: '/api/users' }));
|
|
1505
|
+
app.use('*', authenticateJWT(JWT_SECRET, { prefix: '/api/posts' }));
|
|
1506
|
+
app.use('*', authenticateJWT(JWT_SECRET, { prefix: '/api/tasks' }));
|
|
1508
1507
|
` : "";
|
|
1509
1508
|
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "index.ts"), `import 'dotenv/config';
|
|
1510
1509
|
import { createKozo } from '@kozojs/core';
|
|
1511
|
-
${
|
|
1510
|
+
${authImport}import { registerRoutes } from './routes/index.js';
|
|
1512
1511
|
${servicesSetup}
|
|
1513
1512
|
const app = createKozo({ port: 3000, services });
|
|
1514
1513
|
${authMiddleware}
|
|
@@ -1563,15 +1562,19 @@ export const tasks: z.infer<typeof TaskSchema>[] = [
|
|
|
1563
1562
|
{ id: '3', title: 'Deploy', completed: false, priority: 'low', createdAt: new Date().toISOString() },
|
|
1564
1563
|
];
|
|
1565
1564
|
`);
|
|
1565
|
+
const authRoutesImport = auth ? `import { registerAuthRoutes } from './auth';
|
|
1566
|
+
` : "";
|
|
1567
|
+
const authRoutesCall = auth ? ` registerAuthRoutes(app); // Public auth endpoint must register before JWT middleware
|
|
1568
|
+
` : "";
|
|
1566
1569
|
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "index.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1567
1570
|
import { registerHealthRoutes } from './health';
|
|
1568
1571
|
import { registerUserRoutes } from './users';
|
|
1569
1572
|
import { registerPostRoutes } from './posts';
|
|
1570
1573
|
import { registerTaskRoutes } from './tasks';
|
|
1571
1574
|
import { registerToolRoutes } from './tools';
|
|
1572
|
-
|
|
1575
|
+
${authRoutesImport}
|
|
1573
1576
|
export function registerRoutes(app: Kozo) {
|
|
1574
|
-
registerHealthRoutes(app);
|
|
1577
|
+
${authRoutesCall} registerHealthRoutes(app);
|
|
1575
1578
|
registerUserRoutes(app);
|
|
1576
1579
|
registerPostRoutes(app);
|
|
1577
1580
|
registerTaskRoutes(app);
|
|
@@ -1836,6 +1839,35 @@ export function registerToolRoutes(app: Kozo) {
|
|
|
1836
1839
|
}));
|
|
1837
1840
|
}
|
|
1838
1841
|
`);
|
|
1842
|
+
if (auth) {
|
|
1843
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "auth.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1844
|
+
import { z } from 'zod';
|
|
1845
|
+
import { createJWT, UnauthorizedError } from '@kozojs/auth';
|
|
1846
|
+
|
|
1847
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
|
|
1848
|
+
|
|
1849
|
+
const DEMO_USERS = [
|
|
1850
|
+
{ email: 'admin@demo.com', password: 'admin123', role: 'admin', name: 'Admin' },
|
|
1851
|
+
{ email: 'user@demo.com', password: 'user123', role: 'user', name: 'User' },
|
|
1852
|
+
];
|
|
1853
|
+
|
|
1854
|
+
export function registerAuthRoutes(app: Kozo) {
|
|
1855
|
+
app.post('/api/auth/login', {
|
|
1856
|
+
body: z.object({ email: z.string().email(), password: z.string() }),
|
|
1857
|
+
}, async (c) => {
|
|
1858
|
+
const { email, password } = c.body;
|
|
1859
|
+
const user = DEMO_USERS.find(u => u.email === email && u.password === password);
|
|
1860
|
+
if (!user) throw new UnauthorizedError('Invalid credentials');
|
|
1861
|
+
const token = await createJWT(
|
|
1862
|
+
{ email: user.email, role: user.role, name: user.name },
|
|
1863
|
+
JWT_SECRET,
|
|
1864
|
+
{ expiresIn: '24h' },
|
|
1865
|
+
);
|
|
1866
|
+
return { token, user: { email: user.email, role: user.role, name: user.name } };
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
`);
|
|
1870
|
+
}
|
|
1839
1871
|
}
|
|
1840
1872
|
async function scaffoldFullstackWeb(projectDir, projectName, frontend, auth = false) {
|
|
1841
1873
|
const webDir = import_node_path.default.join(projectDir, "apps", "web");
|
|
@@ -1891,11 +1923,6 @@ export default defineConfig({
|
|
|
1891
1923
|
port: 5173,
|
|
1892
1924
|
proxy: {
|
|
1893
1925
|
'/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 },
|
|
1899
1926
|
},
|
|
1900
1927
|
},
|
|
1901
1928
|
});
|
|
@@ -1976,6 +2003,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
1976
2003
|
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Dashboard.tsx"), generateDashboardPage());
|
|
1977
2004
|
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Users.tsx"), generateUsersPage());
|
|
1978
2005
|
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Posts.tsx"), generatePostsPage());
|
|
2006
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Tasks.tsx"), generateTasksPage());
|
|
1979
2007
|
if (auth) {
|
|
1980
2008
|
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Login.tsx"), generateLoginPage());
|
|
1981
2009
|
}
|
|
@@ -1984,16 +2012,18 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
1984
2012
|
function generateAppTsx(projectName, auth) {
|
|
1985
2013
|
const authImports = auth ? `import { getToken, clearToken } from './lib/api';` : "";
|
|
1986
2014
|
const loginImport = auth ? `import Login from './pages/Login';` : "";
|
|
2015
|
+
const queryClientImport = auth ? `import { useQueryClient } from '@tanstack/react-query';` : "";
|
|
1987
2016
|
return `import { useState${auth ? ", useEffect" : ""} } from 'react';
|
|
1988
|
-
|
|
1989
|
-
import { LayoutDashboard, Users, FileText, Server, LogOut } from 'lucide-react';
|
|
2017
|
+
${queryClientImport}
|
|
2018
|
+
import { LayoutDashboard, Users, FileText, CheckSquare, Server${auth ? ", LogOut" : ""} } from 'lucide-react';
|
|
1990
2019
|
${authImports}
|
|
1991
2020
|
${loginImport}
|
|
1992
2021
|
import Dashboard from './pages/Dashboard';
|
|
1993
2022
|
import UsersPage from './pages/Users';
|
|
1994
2023
|
import PostsPage from './pages/Posts';
|
|
2024
|
+
import TasksPage from './pages/Tasks';
|
|
1995
2025
|
|
|
1996
|
-
type Page = 'dashboard' | 'users' | 'posts';
|
|
2026
|
+
type Page = 'dashboard' | 'users' | 'posts' | 'tasks';
|
|
1997
2027
|
|
|
1998
2028
|
export default function App() {
|
|
1999
2029
|
const [page, setPage] = useState<Page>('dashboard');
|
|
@@ -2010,12 +2040,12 @@ ${auth ? ` const [token, setToken] = useState<string | null>(() => getToken());
|
|
|
2010
2040
|
};
|
|
2011
2041
|
|
|
2012
2042
|
if (!token) return <Login onLogin={handleLogin} />;
|
|
2013
|
-
` :
|
|
2014
|
-
`}
|
|
2043
|
+
` : ""}
|
|
2015
2044
|
const nav = [
|
|
2016
2045
|
{ id: 'dashboard' as Page, label: 'Dashboard', icon: LayoutDashboard },
|
|
2017
2046
|
{ id: 'users' as Page, label: 'Users', icon: Users },
|
|
2018
2047
|
{ id: 'posts' as Page, label: 'Posts', icon: FileText },
|
|
2048
|
+
{ id: 'tasks' as Page, label: 'Tasks', icon: CheckSquare },
|
|
2019
2049
|
];
|
|
2020
2050
|
|
|
2021
2051
|
return (
|
|
@@ -2055,6 +2085,7 @@ ${auth ? ` <button
|
|
|
2055
2085
|
{page === 'dashboard' && <Dashboard />}
|
|
2056
2086
|
{page === 'users' && <UsersPage />}
|
|
2057
2087
|
{page === 'posts' && <PostsPage />}
|
|
2088
|
+
{page === 'tasks' && <TasksPage />}
|
|
2058
2089
|
</main>
|
|
2059
2090
|
</div>
|
|
2060
2091
|
);
|
|
@@ -2071,8 +2102,8 @@ interface Props {
|
|
|
2071
2102
|
}
|
|
2072
2103
|
|
|
2073
2104
|
export default function Login({ onLogin }: Props) {
|
|
2074
|
-
const [email, setEmail] = useState('admin@
|
|
2075
|
-
const [password, setPassword] = useState('
|
|
2105
|
+
const [email, setEmail] = useState('admin@demo.com');
|
|
2106
|
+
const [password, setPassword] = useState('admin123');
|
|
2076
2107
|
const [error, setError] = useState('');
|
|
2077
2108
|
const [loading, setLoading] = useState(false);
|
|
2078
2109
|
|
|
@@ -2081,7 +2112,7 @@ export default function Login({ onLogin }: Props) {
|
|
|
2081
2112
|
setError('');
|
|
2082
2113
|
setLoading(true);
|
|
2083
2114
|
try {
|
|
2084
|
-
const res = await apiFetch<{ token: string }>('/auth/login', {
|
|
2115
|
+
const res = await apiFetch<{ token: string }>('/api/auth/login', {
|
|
2085
2116
|
method: 'POST',
|
|
2086
2117
|
body: JSON.stringify({ email, password }),
|
|
2087
2118
|
});
|
|
@@ -2149,15 +2180,15 @@ export default function Login({ onLogin }: Props) {
|
|
|
2149
2180
|
function generateDashboardPage() {
|
|
2150
2181
|
return `import { useQuery } from '@tanstack/react-query';
|
|
2151
2182
|
import { apiFetch } from '../lib/api';
|
|
2152
|
-
import { Users, FileText,
|
|
2183
|
+
import { Users, FileText, CheckSquare, Zap } from 'lucide-react';
|
|
2153
2184
|
|
|
2185
|
+
interface Health { status: string; uptime: number; version: string; }
|
|
2154
2186
|
interface Stats {
|
|
2155
|
-
users
|
|
2156
|
-
posts
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
version?: string;
|
|
2187
|
+
users: number;
|
|
2188
|
+
posts: number;
|
|
2189
|
+
tasks: number;
|
|
2190
|
+
publishedPosts: number;
|
|
2191
|
+
completedTasks: number;
|
|
2161
2192
|
}
|
|
2162
2193
|
|
|
2163
2194
|
function StatCard({ label, value, sub, icon: Icon, color }: {
|
|
@@ -2183,15 +2214,15 @@ function StatCard({ label, value, sub, icon: Icon, color }: {
|
|
|
2183
2214
|
export default function Dashboard() {
|
|
2184
2215
|
const { data: health } = useQuery({
|
|
2185
2216
|
queryKey: ['health'],
|
|
2186
|
-
queryFn: () => apiFetch<
|
|
2217
|
+
queryFn: () => apiFetch<Health>('/api/health').then(r => r.data),
|
|
2187
2218
|
});
|
|
2188
2219
|
|
|
2189
2220
|
const { data: stats, isLoading } = useQuery({
|
|
2190
2221
|
queryKey: ['stats'],
|
|
2191
|
-
queryFn: () => apiFetch<Stats>('/stats').then(r => r.data),
|
|
2222
|
+
queryFn: () => apiFetch<Stats>('/api/stats').then(r => r.data),
|
|
2192
2223
|
});
|
|
2193
2224
|
|
|
2194
|
-
const uptime = health?.uptime
|
|
2225
|
+
const uptime = health?.uptime;
|
|
2195
2226
|
const uptimeStr = uptime !== undefined
|
|
2196
2227
|
? uptime > 3600 ? \`\${Math.floor(uptime / 3600)}h \${Math.floor((uptime % 3600) / 60)}m\`
|
|
2197
2228
|
: uptime > 60 ? \`\${Math.floor(uptime / 60)}m \${Math.floor(uptime % 60)}s\`
|
|
@@ -2211,24 +2242,24 @@ export default function Dashboard() {
|
|
|
2211
2242
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
|
2212
2243
|
<StatCard
|
|
2213
2244
|
label="Users"
|
|
2214
|
-
value={stats?.users
|
|
2215
|
-
sub={stats?.users ? \`\${stats.users.admins ?? 0} admins \xB7 \${stats.users.regular ?? 0} regular\` : undefined}
|
|
2245
|
+
value={stats?.users ?? '\u2014'}
|
|
2216
2246
|
icon={Users} color="bg-blue-600"
|
|
2217
2247
|
/>
|
|
2218
2248
|
<StatCard
|
|
2219
2249
|
label="Posts"
|
|
2220
|
-
value={stats?.posts
|
|
2221
|
-
sub={stats
|
|
2250
|
+
value={stats?.posts ?? '\u2014'}
|
|
2251
|
+
sub={stats ? \`\${stats.publishedPosts} published\` : undefined}
|
|
2222
2252
|
icon={FileText} color="bg-purple-600"
|
|
2223
2253
|
/>
|
|
2224
2254
|
<StatCard
|
|
2225
|
-
label="
|
|
2226
|
-
value={
|
|
2227
|
-
|
|
2255
|
+
label="Tasks"
|
|
2256
|
+
value={stats?.tasks ?? '\u2014'}
|
|
2257
|
+
sub={stats ? \`\${stats.completedTasks} completed\` : undefined}
|
|
2258
|
+
icon={CheckSquare} color="bg-emerald-600"
|
|
2228
2259
|
/>
|
|
2229
2260
|
<StatCard
|
|
2230
|
-
label="
|
|
2231
|
-
value={
|
|
2261
|
+
label="Uptime"
|
|
2262
|
+
value={uptimeStr}
|
|
2232
2263
|
sub={health?.version ? \`v\${health.version}\` : undefined}
|
|
2233
2264
|
icon={Zap} color="bg-amber-600"
|
|
2234
2265
|
/>
|
|
@@ -2236,10 +2267,10 @@ export default function Dashboard() {
|
|
|
2236
2267
|
)}
|
|
2237
2268
|
|
|
2238
2269
|
<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">
|
|
2270
|
+
<h3 className="text-sm font-medium text-slate-300 mb-3">API Status</h3>
|
|
2240
2271
|
<div className="flex items-center gap-2">
|
|
2241
2272
|
<div className="flex-1 px-3 py-2 bg-slate-900 rounded-lg text-sm text-slate-400 font-mono">
|
|
2242
|
-
GET /health
|
|
2273
|
+
GET /api/health
|
|
2243
2274
|
</div>
|
|
2244
2275
|
<span className={\`px-2 py-0.5 rounded text-xs font-medium \${
|
|
2245
2276
|
health?.status === 'ok' ? 'bg-emerald-900 text-emerald-300' : 'bg-slate-700 text-slate-400'
|
|
@@ -2276,7 +2307,7 @@ export default function UsersPage() {
|
|
|
2276
2307
|
const { data: users = [], isLoading } = useQuery({
|
|
2277
2308
|
queryKey: ['users'],
|
|
2278
2309
|
queryFn: async () => {
|
|
2279
|
-
const res = await apiFetch<User[] | { users: User[] }>('/users');
|
|
2310
|
+
const res = await apiFetch<User[] | { users: User[] }>('/api/users');
|
|
2280
2311
|
return Array.isArray(res.data) ? res.data : res.data.users ?? [];
|
|
2281
2312
|
},
|
|
2282
2313
|
});
|
|
@@ -2285,7 +2316,7 @@ export default function UsersPage() {
|
|
|
2285
2316
|
if (!form.name || !form.email) { setError('Name and email required'); return; }
|
|
2286
2317
|
setLoading(true); setError('');
|
|
2287
2318
|
try {
|
|
2288
|
-
const res = await apiFetch('/users', { method: 'POST', body: JSON.stringify(form) });
|
|
2319
|
+
const res = await apiFetch('/api/users', { method: 'POST', body: JSON.stringify(form) });
|
|
2289
2320
|
if (res.status >= 400) throw new Error('Failed');
|
|
2290
2321
|
setForm({ name: '', email: '' });
|
|
2291
2322
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
@@ -2295,7 +2326,7 @@ export default function UsersPage() {
|
|
|
2295
2326
|
|
|
2296
2327
|
const deleteUser = async (id: string | number) => {
|
|
2297
2328
|
setLoading(true);
|
|
2298
|
-
await apiFetch(\`/users/\${id}\`, { method: 'DELETE' });
|
|
2329
|
+
await apiFetch(\`/api/users/\${id}\`, { method: 'DELETE' });
|
|
2299
2330
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
2300
2331
|
setLoading(false);
|
|
2301
2332
|
};
|
|
@@ -2410,7 +2441,7 @@ export default function PostsPage() {
|
|
|
2410
2441
|
const { data: posts = [], isLoading } = useQuery({
|
|
2411
2442
|
queryKey: ['posts'],
|
|
2412
2443
|
queryFn: async () => {
|
|
2413
|
-
const res = await apiFetch<Post[] | { posts: Post[] }>('/posts');
|
|
2444
|
+
const res = await apiFetch<Post[] | { posts: Post[] }>('/api/posts');
|
|
2414
2445
|
return Array.isArray(res.data) ? res.data : res.data.posts ?? [];
|
|
2415
2446
|
},
|
|
2416
2447
|
});
|
|
@@ -2419,7 +2450,7 @@ export default function PostsPage() {
|
|
|
2419
2450
|
if (!form.title) { setError('Title required'); return; }
|
|
2420
2451
|
setLoading(true); setError('');
|
|
2421
2452
|
try {
|
|
2422
|
-
const res = await apiFetch('/posts', { method: 'POST', body: JSON.stringify(form) });
|
|
2453
|
+
const res = await apiFetch('/api/posts', { method: 'POST', body: JSON.stringify(form) });
|
|
2423
2454
|
if (res.status >= 400) throw new Error('Failed');
|
|
2424
2455
|
setForm({ title: '', content: '', published: false });
|
|
2425
2456
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
@@ -2429,7 +2460,7 @@ export default function PostsPage() {
|
|
|
2429
2460
|
|
|
2430
2461
|
const deletePost = async (id: string | number) => {
|
|
2431
2462
|
setLoading(true);
|
|
2432
|
-
await apiFetch(\`/posts/\${id}\`, { method: 'DELETE' });
|
|
2463
|
+
await apiFetch(\`/api/posts/\${id}\`, { method: 'DELETE' });
|
|
2433
2464
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
2434
2465
|
setLoading(false);
|
|
2435
2466
|
};
|
|
@@ -2524,6 +2555,151 @@ export default function PostsPage() {
|
|
|
2524
2555
|
}
|
|
2525
2556
|
`;
|
|
2526
2557
|
}
|
|
2558
|
+
function generateTasksPage() {
|
|
2559
|
+
return `import { useState } from 'react';
|
|
2560
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2561
|
+
import { apiFetch } from '../lib/api';
|
|
2562
|
+
import { Plus, Trash2, Loader2, CheckCircle2, Circle } from 'lucide-react';
|
|
2563
|
+
|
|
2564
|
+
interface Task {
|
|
2565
|
+
id: string | number;
|
|
2566
|
+
title: string;
|
|
2567
|
+
completed: boolean;
|
|
2568
|
+
priority: 'low' | 'medium' | 'high';
|
|
2569
|
+
createdAt?: string;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
const priorityColor: Record<string, string> = {
|
|
2573
|
+
high: 'bg-red-900 text-red-300',
|
|
2574
|
+
medium: 'bg-amber-900 text-amber-300',
|
|
2575
|
+
low: 'bg-slate-700 text-slate-300',
|
|
2576
|
+
};
|
|
2577
|
+
|
|
2578
|
+
export default function TasksPage() {
|
|
2579
|
+
const queryClient = useQueryClient();
|
|
2580
|
+
const [form, setForm] = useState({ title: '', priority: 'medium' as 'low' | 'medium' | 'high' });
|
|
2581
|
+
const [loading, setLoading] = useState(false);
|
|
2582
|
+
const [error, setError] = useState('');
|
|
2583
|
+
|
|
2584
|
+
const { data: tasks = [], isLoading } = useQuery({
|
|
2585
|
+
queryKey: ['tasks'],
|
|
2586
|
+
queryFn: async () => {
|
|
2587
|
+
const res = await apiFetch<Task[] | { tasks: Task[] }>('/api/tasks');
|
|
2588
|
+
return Array.isArray(res.data) ? res.data : res.data.tasks ?? [];
|
|
2589
|
+
},
|
|
2590
|
+
});
|
|
2591
|
+
|
|
2592
|
+
const createTask = async () => {
|
|
2593
|
+
if (!form.title) { setError('Title required'); return; }
|
|
2594
|
+
setLoading(true); setError('');
|
|
2595
|
+
try {
|
|
2596
|
+
const res = await apiFetch('/api/tasks', { method: 'POST', body: JSON.stringify(form) });
|
|
2597
|
+
if (res.status >= 400) throw new Error('Failed');
|
|
2598
|
+
setForm({ title: '', priority: 'medium' });
|
|
2599
|
+
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2600
|
+
} catch { setError('Failed to create task'); }
|
|
2601
|
+
finally { setLoading(false); }
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
const toggleTask = async (id: string | number) => {
|
|
2605
|
+
await apiFetch(\`/api/tasks/\${id}/toggle\`, { method: 'PATCH' });
|
|
2606
|
+
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
const deleteTask = async (id: string | number) => {
|
|
2610
|
+
setLoading(true);
|
|
2611
|
+
await apiFetch(\`/api/tasks/\${id}\`, { method: 'DELETE' });
|
|
2612
|
+
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2613
|
+
setLoading(false);
|
|
2614
|
+
};
|
|
2615
|
+
|
|
2616
|
+
const done = tasks.filter(t => t.completed).length;
|
|
2617
|
+
|
|
2618
|
+
return (
|
|
2619
|
+
<div>
|
|
2620
|
+
<div className="mb-6">
|
|
2621
|
+
<h1 className="text-2xl font-bold text-slate-100">Tasks</h1>
|
|
2622
|
+
<p className="text-slate-400 text-sm mt-1">{done}/{tasks.length} completed</p>
|
|
2623
|
+
</div>
|
|
2624
|
+
|
|
2625
|
+
{/* Create form */}
|
|
2626
|
+
<div className="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
|
|
2627
|
+
<h3 className="text-sm font-medium text-slate-300 mb-3">New Task</h3>
|
|
2628
|
+
<div className="flex gap-3">
|
|
2629
|
+
<input
|
|
2630
|
+
placeholder="Task title"
|
|
2631
|
+
value={form.title}
|
|
2632
|
+
onChange={e => setForm({ ...form, title: e.target.value })}
|
|
2633
|
+
onKeyDown={e => { if (e.key === 'Enter') { void createTask(); } }}
|
|
2634
|
+
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"
|
|
2635
|
+
/>
|
|
2636
|
+
<select
|
|
2637
|
+
value={form.priority}
|
|
2638
|
+
onChange={e => setForm({ ...form, priority: e.target.value as 'low' | 'medium' | 'high' })}
|
|
2639
|
+
className="px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
|
|
2640
|
+
>
|
|
2641
|
+
<option value="low">Low</option>
|
|
2642
|
+
<option value="medium">Medium</option>
|
|
2643
|
+
<option value="high">High</option>
|
|
2644
|
+
</select>
|
|
2645
|
+
<button
|
|
2646
|
+
onClick={() => { void createTask(); }}
|
|
2647
|
+
disabled={loading}
|
|
2648
|
+
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"
|
|
2649
|
+
>
|
|
2650
|
+
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|
2651
|
+
Add
|
|
2652
|
+
</button>
|
|
2653
|
+
</div>
|
|
2654
|
+
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
|
2655
|
+
</div>
|
|
2656
|
+
|
|
2657
|
+
{/* Tasks list */}
|
|
2658
|
+
<div className="space-y-2">
|
|
2659
|
+
{isLoading ? (
|
|
2660
|
+
<div className="text-center text-slate-400 text-sm py-8">Loading tasks...</div>
|
|
2661
|
+
) : tasks.length === 0 ? (
|
|
2662
|
+
<div className="text-center text-slate-400 text-sm py-8">No tasks yet. Add one above.</div>
|
|
2663
|
+
) : (
|
|
2664
|
+
tasks.map((task) => (
|
|
2665
|
+
<div
|
|
2666
|
+
key={task.id}
|
|
2667
|
+
className={\`flex items-center justify-between p-3 rounded-xl border transition \${
|
|
2668
|
+
task.completed ? 'bg-slate-800/50 border-slate-700/50' : 'bg-slate-800 border-slate-700'
|
|
2669
|
+
}\`}
|
|
2670
|
+
>
|
|
2671
|
+
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
2672
|
+
<button
|
|
2673
|
+
onClick={() => { void toggleTask(task.id); }}
|
|
2674
|
+
className="flex-shrink-0 text-slate-400 hover:text-blue-400 transition"
|
|
2675
|
+
>
|
|
2676
|
+
{task.completed
|
|
2677
|
+
? <CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
|
2678
|
+
: <Circle className="w-5 h-5" />
|
|
2679
|
+
}
|
|
2680
|
+
</button>
|
|
2681
|
+
<span className={\`text-sm font-medium truncate \${task.completed ? 'line-through text-slate-500' : 'text-slate-200'}\`}>
|
|
2682
|
+
{task.title}
|
|
2683
|
+
</span>
|
|
2684
|
+
<span className={\`flex-shrink-0 px-2 py-0.5 rounded-full text-xs font-medium \${priorityColor[task.priority] ?? ''}\`}>
|
|
2685
|
+
{task.priority}
|
|
2686
|
+
</span>
|
|
2687
|
+
</div>
|
|
2688
|
+
<button
|
|
2689
|
+
onClick={() => { void deleteTask(task.id); }}
|
|
2690
|
+
className="ml-3 p-1.5 text-slate-500 hover:text-red-400 transition rounded flex-shrink-0"
|
|
2691
|
+
>
|
|
2692
|
+
<Trash2 className="w-4 h-4" />
|
|
2693
|
+
</button>
|
|
2694
|
+
</div>
|
|
2695
|
+
))
|
|
2696
|
+
)}
|
|
2697
|
+
</div>
|
|
2698
|
+
</div>
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
2701
|
+
`;
|
|
2702
|
+
}
|
|
2527
2703
|
async function scaffoldFullstackReadme(projectDir, projectName) {
|
|
2528
2704
|
const readme = `# ${projectName}
|
|
2529
2705
|
|