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