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.
- checksums.yaml +4 -4
- data/README.md +101 -47
- data/lib/whoosh/app.rb +3 -2
- data/lib/whoosh/cli/client_generator.rb +237 -0
- data/lib/whoosh/cli/main.rb +10 -0
- data/lib/whoosh/client_gen/base_generator.rb +84 -0
- data/lib/whoosh/client_gen/dependency_checker.rb +49 -0
- data/lib/whoosh/client_gen/fallback_backend.rb +292 -0
- data/lib/whoosh/client_gen/generators/expo.rb +1038 -0
- data/lib/whoosh/client_gen/generators/flutter.rb +915 -0
- data/lib/whoosh/client_gen/generators/htmx.rb +498 -0
- data/lib/whoosh/client_gen/generators/ios.rb +832 -0
- data/lib/whoosh/client_gen/generators/react_spa.rb +932 -0
- data/lib/whoosh/client_gen/generators/telegram_bot.rb +624 -0
- data/lib/whoosh/client_gen/generators/telegram_mini_app.rb +844 -0
- data/lib/whoosh/client_gen/introspector.rb +178 -0
- data/lib/whoosh/client_gen/ir.rb +37 -0
- data/lib/whoosh/version.rb +1 -1
- metadata +14 -1
|
@@ -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
|