whoosh 1.5.0 → 1.7.0

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.
@@ -0,0 +1,932 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "whoosh/client_gen/base_generator"
5
+
6
+ module Whoosh
7
+ module ClientGen
8
+ module Generators
9
+ class ReactSpa < BaseGenerator
10
+ def generate
11
+ generate_config_files
12
+ generate_api_client
13
+ generate_auth_api if ir.has_auth?
14
+ ir.resources.each do |resource|
15
+ generate_model(resource)
16
+ generate_resource_api(resource)
17
+ generate_resource_hook(resource)
18
+ generate_resource_pages(resource)
19
+ end
20
+ generate_auth_hook if ir.has_auth?
21
+ generate_components
22
+ generate_router
23
+ generate_app
24
+ generate_main
25
+ generate_styles
26
+ end
27
+
28
+ private
29
+
30
+ # ── Config files ──────────────────────────────────────────────
31
+
32
+ def generate_config_files
33
+ write_file("package.json", package_json)
34
+ write_file("tsconfig.json", tsconfig_json)
35
+ write_file("vite.config.ts", vite_config)
36
+ write_file("index.html", index_html)
37
+ write_file(".env", dot_env)
38
+ write_file(".gitignore", gitignore)
39
+ end
40
+
41
+ def package_json
42
+ pkg = {
43
+ name: "app",
44
+ private: true,
45
+ version: "0.1.0",
46
+ type: "module",
47
+ scripts: {
48
+ dev: "vite",
49
+ build: "tsc && vite build",
50
+ preview: "vite preview"
51
+ },
52
+ dependencies: {
53
+ "react" => "^19.0.0",
54
+ "react-dom" => "^19.0.0",
55
+ "react-router-dom" => "^7.0.0"
56
+ },
57
+ devDependencies: {
58
+ "@types/react" => "^19.0.0",
59
+ "@types/react-dom" => "^19.0.0",
60
+ "@vitejs/plugin-react" => "^4.3.0",
61
+ "typescript" => "^5.6.0",
62
+ "vite" => "^6.0.0"
63
+ }
64
+ }
65
+ JSON.pretty_generate(pkg) + "\n"
66
+ end
67
+
68
+ def tsconfig_json
69
+ <<~JSON
70
+ {
71
+ "compilerOptions": {
72
+ "target": "ES2020",
73
+ "useDefineForClassFields": true,
74
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
75
+ "module": "ESNext",
76
+ "skipLibCheck": true,
77
+ "moduleResolution": "bundler",
78
+ "allowImportingTsExtensions": true,
79
+ "isolatedModules": true,
80
+ "moduleDetection": "force",
81
+ "noEmit": true,
82
+ "jsx": "react-jsx",
83
+ "strict": true,
84
+ "noUnusedLocals": false,
85
+ "noUnusedParameters": false,
86
+ "noFallthroughCasesInSwitch": true,
87
+ "forceConsistentCasingInFileNames": true
88
+ },
89
+ "include": ["src"]
90
+ }
91
+ JSON
92
+ end
93
+
94
+ def vite_config
95
+ <<~TS
96
+ import { defineConfig } from "vite";
97
+ import react from "@vitejs/plugin-react";
98
+
99
+ export default defineConfig({
100
+ plugins: [react()],
101
+ });
102
+ TS
103
+ end
104
+
105
+ def index_html
106
+ <<~HTML
107
+ <!DOCTYPE html>
108
+ <html lang="en">
109
+ <head>
110
+ <meta charset="UTF-8" />
111
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
112
+ <title>App</title>
113
+ </head>
114
+ <body>
115
+ <div id="root"></div>
116
+ <script type="module" src="/src/main.tsx"></script>
117
+ </body>
118
+ </html>
119
+ HTML
120
+ end
121
+
122
+ def dot_env
123
+ "VITE_API_URL=#{ir.base_url}\n"
124
+ end
125
+
126
+ def gitignore
127
+ <<~TXT
128
+ node_modules
129
+ dist
130
+ .env.local
131
+ TXT
132
+ end
133
+
134
+ # ── API client ────────────────────────────────────────────────
135
+
136
+ def generate_api_client
137
+ write_file("src/api/client.ts", <<~TS)
138
+ const API_URL = import.meta.env.VITE_API_URL || "#{ir.base_url}";
139
+
140
+ export function getTokens() {
141
+ return {
142
+ access: localStorage.getItem("access_token"),
143
+ refresh: localStorage.getItem("refresh_token"),
144
+ };
145
+ }
146
+
147
+ export function setTokens(access: string, refresh?: string) {
148
+ localStorage.setItem("access_token", access);
149
+ if (refresh) localStorage.setItem("refresh_token", refresh);
150
+ }
151
+
152
+ export function clearTokens() {
153
+ localStorage.removeItem("access_token");
154
+ localStorage.removeItem("refresh_token");
155
+ }
156
+
157
+ async function refreshAccessToken(): Promise<boolean> {
158
+ const { refresh } = getTokens();
159
+ if (!refresh) return false;
160
+ try {
161
+ const res = await fetch(`${API_URL}/auth/refresh`, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({ refresh_token: refresh }),
165
+ });
166
+ if (!res.ok) return false;
167
+ const data = await res.json();
168
+ setTokens(data.access_token, data.refresh_token);
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ export async function apiRequest<T = any>(
176
+ path: string,
177
+ options: RequestInit = {}
178
+ ): Promise<T> {
179
+ const { access } = getTokens();
180
+ const headers: Record<string, string> = {
181
+ "Content-Type": "application/json",
182
+ ...(options.headers as Record<string, string>),
183
+ };
184
+ if (access) {
185
+ headers["Authorization"] = `Bearer ${access}`;
186
+ }
187
+
188
+ let res = await fetch(`${API_URL}${path}`, { ...options, headers });
189
+
190
+ if (res.status === 401) {
191
+ const refreshed = await refreshAccessToken();
192
+ if (refreshed) {
193
+ const { access: newAccess } = getTokens();
194
+ headers["Authorization"] = `Bearer ${newAccess}`;
195
+ res = await fetch(`${API_URL}${path}`, { ...options, headers });
196
+ }
197
+ }
198
+
199
+ if (!res.ok) {
200
+ const err = await res.json().catch(() => ({}));
201
+ throw { status: res.status, ...err };
202
+ }
203
+
204
+ if (res.status === 204) return undefined as T;
205
+ return res.json();
206
+ }
207
+ TS
208
+ end
209
+
210
+ # ── Auth API ──────────────────────────────────────────────────
211
+
212
+ def generate_auth_api
213
+ write_file("src/api/auth.ts", <<~TS)
214
+ import { apiRequest, setTokens, clearTokens } from "./client";
215
+
216
+ export async function login(email: string, password: string) {
217
+ const data = await apiRequest<{ access_token: string; refresh_token: string }>(
218
+ "/auth/login",
219
+ { method: "POST", body: JSON.stringify({ email, password }) }
220
+ );
221
+ setTokens(data.access_token, data.refresh_token);
222
+ return data;
223
+ }
224
+
225
+ export async function register(email: string, password: string) {
226
+ const data = await apiRequest<{ access_token: string; refresh_token: string }>(
227
+ "/auth/register",
228
+ { method: "POST", body: JSON.stringify({ email, password }) }
229
+ );
230
+ setTokens(data.access_token, data.refresh_token);
231
+ return data;
232
+ }
233
+
234
+ export async function refresh() {
235
+ const data = await apiRequest<{ access_token: string; refresh_token: string }>(
236
+ "/auth/refresh",
237
+ { method: "POST" }
238
+ );
239
+ setTokens(data.access_token, data.refresh_token);
240
+ return data;
241
+ }
242
+
243
+ export async function logout() {
244
+ await apiRequest("/auth/logout", { method: "DELETE" });
245
+ clearTokens();
246
+ }
247
+
248
+ export async function getMe() {
249
+ return apiRequest<{ id: string; email: string }>("/auth/me");
250
+ }
251
+ TS
252
+ end
253
+
254
+ # ── Models ────────────────────────────────────────────────────
255
+
256
+ def generate_model(resource)
257
+ name = classify(resource.name)
258
+ singular = singularize(resource.name.to_s)
259
+ fields = resource.fields || []
260
+
261
+ lines = fields.map do |f|
262
+ fname = f[:name].to_s
263
+ ftype = type_for(f[:type])
264
+ " #{fname}: #{ftype};"
265
+ end
266
+
267
+ create_lines = fields.select { |f| f[:required] }.map do |f|
268
+ " #{f[:name]}: #{type_for(f[:type])};"
269
+ end
270
+ create_optional = fields.reject { |f| f[:required] }.map do |f|
271
+ " #{f[:name]}?: #{type_for(f[:type])};"
272
+ end
273
+
274
+ update_lines = fields.map do |f|
275
+ " #{f[:name]}?: #{type_for(f[:type])};"
276
+ end
277
+
278
+ write_file("src/models/#{singular}.ts", <<~TS)
279
+ export interface #{name} {
280
+ id: string;
281
+ #{lines.join("\n")}
282
+ created_at?: string;
283
+ updated_at?: string;
284
+ }
285
+
286
+ export interface Create#{name}Input {
287
+ #{(create_lines + create_optional).join("\n")}
288
+ }
289
+
290
+ export interface Update#{name}Input {
291
+ #{update_lines.join("\n")}
292
+ }
293
+ TS
294
+ end
295
+
296
+ # ── Resource API ──────────────────────────────────────────────
297
+
298
+ def generate_resource_api(resource)
299
+ plural = resource.name.to_s
300
+ singular = singularize(plural)
301
+ name = classify(resource.name)
302
+ has_pagination = resource.endpoints.any? { |e| e.pagination }
303
+
304
+ list_return = has_pagination ? "Promise<{ data: #{name}[]; cursor?: string }>" : "Promise<#{name}[]>"
305
+ list_params = has_pagination ? "cursor?: string" : ""
306
+ list_query = has_pagination ? 'const query = cursor ? `?cursor=${cursor}` : "";\n ' : ""
307
+ list_path = has_pagination ? "\"/#{plural}${query}\"" : "\"/#{plural}\""
308
+
309
+ write_file("src/api/#{plural}.ts", <<~TS)
310
+ import { apiRequest } from "./client";
311
+ import type { #{name}, Create#{name}Input, Update#{name}Input } from "../models/#{singular}";
312
+
313
+ export async function list#{name}s(#{list_params}): #{list_return} {
314
+ #{list_query}return apiRequest(#{list_path});
315
+ }
316
+
317
+ export async function get#{name}(id: string): Promise<#{name}> {
318
+ return apiRequest(`/#{plural}/${String("${id}")}`);
319
+ }
320
+
321
+ export async function create#{name}(input: Create#{name}Input): Promise<#{name}> {
322
+ return apiRequest("/#{plural}", {
323
+ method: "POST",
324
+ body: JSON.stringify(input),
325
+ });
326
+ }
327
+
328
+ export async function update#{name}(id: string, input: Update#{name}Input): Promise<#{name}> {
329
+ return apiRequest(`/#{plural}/${String("${id}")}`, {
330
+ method: "PUT",
331
+ body: JSON.stringify(input),
332
+ });
333
+ }
334
+
335
+ export async function delete#{name}(id: string): Promise<void> {
336
+ return apiRequest(`/#{plural}/${String("${id}")}`, { method: "DELETE" });
337
+ }
338
+ TS
339
+ end
340
+
341
+ # ── Hooks ─────────────────────────────────────────────────────
342
+
343
+ def generate_auth_hook
344
+ write_file("src/hooks/useAuth.ts", <<~TS)
345
+ import { createContext, useContext, useState, useEffect, useCallback } from "react";
346
+ import * as authApi from "../api/auth";
347
+ import { getTokens, clearTokens } from "../api/client";
348
+
349
+ interface User {
350
+ id: string;
351
+ email: string;
352
+ }
353
+
354
+ interface AuthContextValue {
355
+ user: User | null;
356
+ loading: boolean;
357
+ login: (email: string, password: string) => Promise<void>;
358
+ register: (email: string, password: string) => Promise<void>;
359
+ logout: () => Promise<void>;
360
+ }
361
+
362
+ export const AuthContext = createContext<AuthContextValue | null>(null);
363
+
364
+ export function useAuth(): AuthContextValue {
365
+ const ctx = useContext(AuthContext);
366
+ if (!ctx) throw new Error("useAuth must be inside AuthProvider");
367
+ return ctx;
368
+ }
369
+
370
+ export function useAuthProvider(): AuthContextValue {
371
+ const [user, setUser] = useState<User | null>(null);
372
+ const [loading, setLoading] = useState(true);
373
+
374
+ const fetchMe = useCallback(async () => {
375
+ try {
376
+ const me = await authApi.getMe();
377
+ setUser(me);
378
+ } catch {
379
+ clearTokens();
380
+ setUser(null);
381
+ } finally {
382
+ setLoading(false);
383
+ }
384
+ }, []);
385
+
386
+ useEffect(() => {
387
+ const { access } = getTokens();
388
+ if (access) {
389
+ fetchMe();
390
+ } else {
391
+ setLoading(false);
392
+ }
393
+ }, [fetchMe]);
394
+
395
+ const login = async (email: string, password: string) => {
396
+ await authApi.login(email, password);
397
+ await fetchMe();
398
+ };
399
+
400
+ const register = async (email: string, password: string) => {
401
+ await authApi.register(email, password);
402
+ await fetchMe();
403
+ };
404
+
405
+ const logout = async () => {
406
+ await authApi.logout();
407
+ setUser(null);
408
+ };
409
+
410
+ return { user, loading, login, register, logout };
411
+ }
412
+ TS
413
+ end
414
+
415
+ def generate_resource_hook(resource)
416
+ plural = resource.name.to_s
417
+ singular = singularize(plural)
418
+ name = classify(resource.name)
419
+ has_pagination = resource.endpoints.any? { |e| e.pagination }
420
+
421
+ write_file("src/hooks/use#{name}s.ts", <<~TS)
422
+ import { useState, useCallback } from "react";
423
+ import * as api from "../api/#{plural}";
424
+ import type { #{name}, Create#{name}Input, Update#{name}Input } from "../models/#{singular}";
425
+
426
+ export function use#{name}s() {
427
+ const [items, setItems] = useState<#{name}[]>([]);
428
+ const [loading, setLoading] = useState(false);
429
+ const [error, setError] = useState<string | null>(null);
430
+ #{has_pagination ? 'const [cursor, setCursor] = useState<string | undefined>();' : ''}
431
+
432
+ const fetchAll = useCallback(async (#{has_pagination ? 'nextCursor?: string' : ''}) => {
433
+ setLoading(true);
434
+ setError(null);
435
+ try {
436
+ #{if has_pagination
437
+ "const result = await api.list#{name}s(nextCursor);\n" \
438
+ " if (nextCursor) {\n" \
439
+ " setItems((prev) => [...prev, ...result.data]);\n" \
440
+ " } else {\n" \
441
+ " setItems(result.data);\n" \
442
+ " }\n" \
443
+ " setCursor(result.cursor);"
444
+ else
445
+ "const data = await api.list#{name}s();\n setItems(data);"
446
+ end}
447
+ } catch (e: any) {
448
+ setError(e.message || "Failed to load");
449
+ } finally {
450
+ setLoading(false);
451
+ }
452
+ }, []);
453
+
454
+ const create = async (input: Create#{name}Input) => {
455
+ const item = await api.create#{name}(input);
456
+ setItems((prev) => [...prev, item]);
457
+ return item;
458
+ };
459
+
460
+ const update = async (id: string, input: Update#{name}Input) => {
461
+ const item = await api.update#{name}(id, input);
462
+ setItems((prev) => prev.map((i) => (i.id === id ? item : i)));
463
+ return item;
464
+ };
465
+
466
+ const remove = async (id: string) => {
467
+ await api.delete#{name}(id);
468
+ setItems((prev) => prev.filter((i) => i.id !== id));
469
+ };
470
+
471
+ return { items, loading, error, fetchAll, create, update, remove#{has_pagination ? ', cursor' : ''} };
472
+ }
473
+ TS
474
+ end
475
+
476
+ # ── Pages ─────────────────────────────────────────────────────
477
+
478
+ def generate_resource_pages(resource)
479
+ plural = resource.name.to_s
480
+ singular = singularize(plural)
481
+ name = classify(resource.name)
482
+ fields = resource.fields || []
483
+ has_pagination = resource.endpoints.any? { |e| e.pagination }
484
+
485
+ generate_list_page(name, plural, fields, has_pagination)
486
+ generate_detail_page(name, plural, singular, fields)
487
+ generate_form_page(name, plural, singular, fields)
488
+ generate_login_page if ir.has_auth?
489
+ generate_register_page if ir.has_auth?
490
+ end
491
+
492
+ def generate_login_page
493
+ write_file("src/pages/Login.tsx", <<~TSX)
494
+ import { useState, FormEvent } from "react";
495
+ import { useAuth } from "../hooks/useAuth";
496
+ import { useNavigate, Link } from "react-router-dom";
497
+
498
+ export default function Login() {
499
+ const { login } = useAuth();
500
+ const navigate = useNavigate();
501
+ const [email, setEmail] = useState("");
502
+ const [password, setPassword] = useState("");
503
+ const [error, setError] = useState<string | null>(null);
504
+
505
+ const handleSubmit = async (e: FormEvent) => {
506
+ e.preventDefault();
507
+ setError(null);
508
+ try {
509
+ await login(email, password);
510
+ navigate("/");
511
+ } catch (err: any) {
512
+ setError(err.message || "Login failed");
513
+ }
514
+ };
515
+
516
+ return (
517
+ <div className="auth-page">
518
+ <h1>Login</h1>
519
+ {error && <p className="error">{error}</p>}
520
+ <form onSubmit={handleSubmit}>
521
+ <input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required />
522
+ <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required />
523
+ <button type="submit">Login</button>
524
+ </form>
525
+ <p>Don't have an account? <Link to="/register">Register</Link></p>
526
+ </div>
527
+ );
528
+ }
529
+ TSX
530
+ end
531
+
532
+ def generate_register_page
533
+ write_file("src/pages/Register.tsx", <<~TSX)
534
+ import { useState, FormEvent } from "react";
535
+ import { useAuth } from "../hooks/useAuth";
536
+ import { useNavigate, Link } from "react-router-dom";
537
+
538
+ export default function Register() {
539
+ const { register } = useAuth();
540
+ const navigate = useNavigate();
541
+ const [email, setEmail] = useState("");
542
+ const [password, setPassword] = useState("");
543
+ const [error, setError] = useState<string | null>(null);
544
+
545
+ const handleSubmit = async (e: FormEvent) => {
546
+ e.preventDefault();
547
+ setError(null);
548
+ try {
549
+ await register(email, password);
550
+ navigate("/");
551
+ } catch (err: any) {
552
+ setError(err.message || "Registration failed");
553
+ }
554
+ };
555
+
556
+ return (
557
+ <div className="auth-page">
558
+ <h1>Register</h1>
559
+ {error && <p className="error">{error}</p>}
560
+ <form onSubmit={handleSubmit}>
561
+ <input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required />
562
+ <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required />
563
+ <button type="submit">Register</button>
564
+ </form>
565
+ <p>Already have an account? <Link to="/login">Login</Link></p>
566
+ </div>
567
+ );
568
+ }
569
+ TSX
570
+ end
571
+
572
+ def generate_list_page(name, plural, fields, has_pagination)
573
+ display_fields = fields.first(3)
574
+ field_headers = display_fields.map { |f| "<th>#{camelize(f[:name].to_s)}</th>" }.join("\n ")
575
+ field_cells = display_fields.map { |f| "<td>{item.#{f[:name]}}</td>" }.join("\n ")
576
+
577
+ write_file("src/pages/#{name}List.tsx", <<~TSX)
578
+ import { useEffect } from "react";
579
+ import { Link } from "react-router-dom";
580
+ import { use#{name}s } from "../hooks/use#{name}s";
581
+ #{has_pagination ? 'import Pagination from "../components/Pagination";' : ''}
582
+
583
+ export default function #{name}List() {
584
+ const { items, loading, error, fetchAll, remove#{has_pagination ? ', cursor' : ''} } = use#{name}s();
585
+
586
+ useEffect(() => { fetchAll(); }, [fetchAll]);
587
+
588
+ if (loading && items.length === 0) return <p>Loading...</p>;
589
+ if (error) return <p className="error">{error}</p>;
590
+
591
+ return (
592
+ <div>
593
+ <h1>#{name}s</h1>
594
+ <Link to="/#{plural}/new" className="btn">Create #{name}</Link>
595
+ <table>
596
+ <thead>
597
+ <tr>
598
+ #{field_headers}
599
+ <th>Actions</th>
600
+ </tr>
601
+ </thead>
602
+ <tbody>
603
+ {items.map((item) => (
604
+ <tr key={item.id}>
605
+ #{field_cells}
606
+ <td>
607
+ <Link to={`/#{plural}/${String("${item.id}")}`}>View</Link>
608
+ {" | "}
609
+ <button onClick={() => remove(item.id)}>Delete</button>
610
+ </td>
611
+ </tr>
612
+ ))}
613
+ </tbody>
614
+ </table>
615
+ #{has_pagination ? "<Pagination cursor={cursor} onLoadMore={() => fetchAll(cursor)} loading={loading} />" : ''}
616
+ </div>
617
+ );
618
+ }
619
+ TSX
620
+ end
621
+
622
+ def generate_detail_page(name, plural, singular, fields)
623
+ field_rows = fields.map { |f| "<p><strong>#{camelize(f[:name].to_s)}:</strong> {item.#{f[:name]} ?? \"—\"}</p>" }.join("\n ")
624
+
625
+ write_file("src/pages/#{name}Detail.tsx", <<~TSX)
626
+ import { useEffect, useState } from "react";
627
+ import { useParams, useNavigate, Link } from "react-router-dom";
628
+ import { get#{name}, delete#{name} } from "../api/#{plural}";
629
+ import type { #{name} } from "../models/#{singular}";
630
+
631
+ export default function #{name}Detail() {
632
+ const { id } = useParams<{ id: string }>();
633
+ const navigate = useNavigate();
634
+ const [item, setItem] = useState<#{name} | null>(null);
635
+
636
+ useEffect(() => {
637
+ if (id) get#{name}(id).then(setItem);
638
+ }, [id]);
639
+
640
+ if (!item) return <p>Loading...</p>;
641
+
642
+ const handleDelete = async () => {
643
+ await delete#{name}(item.id);
644
+ navigate("/#{plural}");
645
+ };
646
+
647
+ return (
648
+ <div>
649
+ <h1>#{name} Detail</h1>
650
+ #{field_rows}
651
+ <Link to={`/#{plural}/${String("${item.id}")}/edit`} className="btn">Edit</Link>
652
+ {" "}
653
+ <button onClick={handleDelete} className="btn-danger">Delete</button>
654
+ <br />
655
+ <Link to="/#{plural}">Back to list</Link>
656
+ </div>
657
+ );
658
+ }
659
+ TSX
660
+ end
661
+
662
+ def generate_form_page(name, plural, singular, fields)
663
+ state_lines = fields.map do |f|
664
+ default_val = f[:default] ? "\"#{f[:default]}\"" : "\"\""
665
+ "const [#{f[:name]}, set#{camelize(f[:name].to_s)}] = useState(#{default_val});"
666
+ end.join("\n ")
667
+
668
+ load_lines = fields.map { |f| "set#{camelize(f[:name].to_s)}(data.#{f[:name]} ?? \"\");" }.join("\n ")
669
+
670
+ input_fields = fields.map do |f|
671
+ fname = f[:name].to_s
672
+ setter = "set#{camelize(fname)}"
673
+ if f[:enum]
674
+ options = f[:enum].map { |v| "<option value=\"#{v}\">#{v}</option>" }.join("\n ")
675
+ <<~FIELD.strip
676
+ <label>#{camelize(fname)}
677
+ <select value={#{fname}} onChange={(e) => #{setter}(e.target.value)}>
678
+ <option value="">Select...</option>
679
+ #{options}
680
+ </select>
681
+ </label>
682
+ FIELD
683
+ else
684
+ required_attr = f[:required] ? " required" : ""
685
+ <<~FIELD.strip
686
+ <label>#{camelize(fname)}
687
+ <input value={#{fname}} onChange={(e) => #{setter}(e.target.value)}#{required_attr} />
688
+ </label>
689
+ FIELD
690
+ end
691
+ end.join("\n ")
692
+
693
+ body_fields = fields.map { |f| "#{f[:name]}" }.join(", ")
694
+
695
+ write_file("src/pages/#{name}Form.tsx", <<~TSX)
696
+ import { useState, useEffect, FormEvent } from "react";
697
+ import { useParams, useNavigate } from "react-router-dom";
698
+ import { get#{name}, create#{name}, update#{name} } from "../api/#{plural}";
699
+
700
+ export default function #{name}Form() {
701
+ const { id } = useParams<{ id: string }>();
702
+ const navigate = useNavigate();
703
+ const isEdit = Boolean(id);
704
+ #{state_lines}
705
+ const [error, setError] = useState<string | null>(null);
706
+
707
+ useEffect(() => {
708
+ if (id) {
709
+ get#{name}(id).then((data) => {
710
+ #{load_lines}
711
+ });
712
+ }
713
+ }, [id]);
714
+
715
+ const handleSubmit = async (e: FormEvent) => {
716
+ e.preventDefault();
717
+ setError(null);
718
+ try {
719
+ const body = { #{body_fields} };
720
+ if (isEdit && id) {
721
+ await update#{name}(id, body);
722
+ } else {
723
+ await create#{name}(body);
724
+ }
725
+ navigate("/#{plural}");
726
+ } catch (err: any) {
727
+ setError(err.message || "Save failed");
728
+ }
729
+ };
730
+
731
+ return (
732
+ <div>
733
+ <h1>{isEdit ? "Edit" : "New"} #{name}</h1>
734
+ {error && <p className="error">{error}</p>}
735
+ <form onSubmit={handleSubmit}>
736
+ #{input_fields}
737
+ <button type="submit">{isEdit ? "Update" : "Create"}</button>
738
+ </form>
739
+ </div>
740
+ );
741
+ }
742
+ TSX
743
+ end
744
+
745
+ # ── Components ────────────────────────────────────────────────
746
+
747
+ def generate_components
748
+ generate_layout
749
+ generate_protected_route
750
+ generate_pagination
751
+ end
752
+
753
+ def generate_layout
754
+ resource_links = ir.resources.map do |r|
755
+ plural = r.name.to_s
756
+ name = classify(r.name)
757
+ "<Link to=\"/#{plural}\">#{name}s</Link>"
758
+ end.join("\n ")
759
+
760
+ write_file("src/components/Layout.tsx", <<~TSX)
761
+ import { Outlet, Link } from "react-router-dom";
762
+ import { useAuth } from "../hooks/useAuth";
763
+
764
+ export default function Layout() {
765
+ const { user, logout } = useAuth();
766
+
767
+ return (
768
+ <div className="layout">
769
+ <nav>
770
+ <Link to="/">Home</Link>
771
+ #{resource_links}
772
+ {user ? (
773
+ <>
774
+ <span>{user.email}</span>
775
+ <button onClick={logout}>Logout</button>
776
+ </>
777
+ ) : (
778
+ <Link to="/login">Login</Link>
779
+ )}
780
+ </nav>
781
+ <main>
782
+ <Outlet />
783
+ </main>
784
+ </div>
785
+ );
786
+ }
787
+ TSX
788
+ end
789
+
790
+ def generate_protected_route
791
+ write_file("src/components/ProtectedRoute.tsx", <<~TSX)
792
+ import { Navigate, Outlet } from "react-router-dom";
793
+ import { useAuth } from "../hooks/useAuth";
794
+
795
+ export default function ProtectedRoute() {
796
+ const { user, loading } = useAuth();
797
+ if (loading) return <p>Loading...</p>;
798
+ if (!user) return <Navigate to="/login" replace />;
799
+ return <Outlet />;
800
+ }
801
+ TSX
802
+ end
803
+
804
+ def generate_pagination
805
+ write_file("src/components/Pagination.tsx", <<~TSX)
806
+ interface Props {
807
+ cursor?: string;
808
+ onLoadMore: () => void;
809
+ loading: boolean;
810
+ }
811
+
812
+ export default function Pagination({ cursor, onLoadMore, loading }: Props) {
813
+ if (!cursor) return null;
814
+ return (
815
+ <div className="pagination">
816
+ <button onClick={onLoadMore} disabled={loading}>
817
+ {loading ? "Loading..." : "Load more"}
818
+ </button>
819
+ </div>
820
+ );
821
+ }
822
+ TSX
823
+ end
824
+
825
+ # ── Router / App / Main ───────────────────────────────────────
826
+
827
+ def generate_router
828
+ resource_imports = ir.resources.map do |r|
829
+ name = classify(r.name)
830
+ plural = r.name.to_s
831
+ <<~TS.chomp
832
+ import #{name}List from "./pages/#{name}List";
833
+ import #{name}Detail from "./pages/#{name}Detail";
834
+ import #{name}Form from "./pages/#{name}Form";
835
+ TS
836
+ end.join("\n")
837
+
838
+ resource_routes = ir.resources.map do |r|
839
+ name = classify(r.name)
840
+ plural = r.name.to_s
841
+ <<~TSX.chomp
842
+ <Route path="/#{plural}" element={<#{name}List />} />
843
+ <Route path="/#{plural}/new" element={<#{name}Form />} />
844
+ <Route path="/#{plural}/:id" element={<#{name}Detail />} />
845
+ <Route path="/#{plural}/:id/edit" element={<#{name}Form />} />
846
+ TSX
847
+ end.join("\n")
848
+
849
+ write_file("src/router.tsx", <<~TSX)
850
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
851
+ import Layout from "./components/Layout";
852
+ import ProtectedRoute from "./components/ProtectedRoute";
853
+ import Login from "./pages/Login";
854
+ import Register from "./pages/Register";
855
+ #{resource_imports}
856
+
857
+ export default function AppRouter() {
858
+ return (
859
+ <BrowserRouter>
860
+ <Routes>
861
+ <Route element={<Layout />}>
862
+ <Route path="/login" element={<Login />} />
863
+ <Route path="/register" element={<Register />} />
864
+ <Route element={<ProtectedRoute />}>
865
+ #{resource_routes}
866
+ </Route>
867
+ </Route>
868
+ </Routes>
869
+ </BrowserRouter>
870
+ );
871
+ }
872
+ TSX
873
+ end
874
+
875
+ def generate_app
876
+ write_file("src/App.tsx", <<~TSX)
877
+ import { AuthContext, useAuthProvider } from "./hooks/useAuth";
878
+ import AppRouter from "./router";
879
+
880
+ export default function App() {
881
+ const auth = useAuthProvider();
882
+
883
+ return (
884
+ <AuthContext.Provider value={auth}>
885
+ <AppRouter />
886
+ </AuthContext.Provider>
887
+ );
888
+ }
889
+ TSX
890
+ end
891
+
892
+ def generate_main
893
+ write_file("src/main.tsx", <<~TSX)
894
+ import { StrictMode } from "react";
895
+ import { createRoot } from "react-dom/client";
896
+ import App from "./App";
897
+ import "./styles.css";
898
+
899
+ createRoot(document.getElementById("root")!).render(
900
+ <StrictMode>
901
+ <App />
902
+ </StrictMode>
903
+ );
904
+ TSX
905
+ end
906
+
907
+ def generate_styles
908
+ write_file("src/styles.css", <<~CSS)
909
+ * { box-sizing: border-box; margin: 0; padding: 0; }
910
+ body { font-family: system-ui, sans-serif; line-height: 1.6; color: #333; }
911
+ .layout { max-width: 960px; margin: 0 auto; padding: 1rem; }
912
+ nav { display: flex; gap: 1rem; align-items: center; padding: 1rem 0; border-bottom: 1px solid #ddd; margin-bottom: 1rem; }
913
+ nav a { text-decoration: none; color: #0066cc; }
914
+ table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
915
+ th, td { padding: 0.5rem; border: 1px solid #ddd; text-align: left; }
916
+ th { background: #f5f5f5; }
917
+ form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 400px; }
918
+ label { display: flex; flex-direction: column; gap: 0.25rem; }
919
+ input, select { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
920
+ button, .btn { padding: 0.5rem 1rem; background: #0066cc; color: #fff; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; }
921
+ button:hover, .btn:hover { background: #0052a3; }
922
+ .btn-danger { background: #cc0000; }
923
+ .btn-danger:hover { background: #990000; }
924
+ .error { color: #cc0000; margin: 0.5rem 0; }
925
+ .auth-page { max-width: 400px; margin: 2rem auto; }
926
+ .pagination { margin: 1rem 0; }
927
+ CSS
928
+ end
929
+ end
930
+ end
931
+ end
932
+ end