1use gize_core::naming::table_name;
14use gize_core::{FieldType, Manifest};
15
16pub fn package_json(project: &str) -> String {
18 format!(
19 r#"{{
20 "name": "{project}-admin",
21 "private": true,
22 "type": "module",
23 "scripts": {{
24 "dev": "vite",
25 "build": "tsc --noEmit && vite build",
26 "preview": "vite preview"
27 }},
28 "dependencies": {{
29 "@hookform/resolvers": "^3.9.0",
30 "@tanstack/react-query": "^5.59.0",
31 "react": "^18.3.1",
32 "react-dom": "^18.3.1",
33 "react-hook-form": "^7.53.0",
34 "zod": "^3.23.8"
35 }},
36 "devDependencies": {{
37 "@types/react": "^18.3.11",
38 "@types/react-dom": "^18.3.1",
39 "@vitejs/plugin-react": "^4.3.2",
40 "typescript": "^5.6.2",
41 "vite": "^5.4.8"
42 }}
43}}
44"#
45 )
46}
47
48pub fn vite_config() -> String {
50 r#"import { defineConfig } from "vite";
51import react from "@vitejs/plugin-react";
52
53// The API base is proxied so the browser talks same-origin in dev. Set VITE_API_URL to point
54// at your running Gize backend (default http://localhost:8080).
55export default defineConfig({
56 plugins: [react()],
57 server: {
58 proxy: {
59 "/api": {
60 target: process.env.VITE_API_URL || "http://localhost:8080",
61 changeOrigin: true,
62 rewrite: (p) => p.replace(/^\/api/, ""),
63 },
64 },
65 },
66});
67"#
68 .to_string()
69}
70
71pub fn tsconfig() -> String {
73 r#"{
74 "compilerOptions": {
75 "target": "ES2020",
76 "useDefineForClassFields": true,
77 "lib": ["ES2020", "DOM", "DOM.Iterable"],
78 "module": "ESNext",
79 "skipLibCheck": true,
80 "moduleResolution": "bundler",
81 "allowImportingTsExtensions": true,
82 "resolveJsonModule": true,
83 "isolatedModules": true,
84 "noEmit": true,
85 "jsx": "react-jsx",
86 "strict": true,
87 "noUnusedLocals": false,
88 "noUnusedParameters": false,
89 "noFallthroughCasesInSwitch": true
90 },
91 "include": ["src"]
92}
93"#
94 .to_string()
95}
96
97pub fn index_html() -> String {
99 r####"<!doctype html>
100<html lang="en">
101 <head>
102 <meta charset="UTF-8" />
103 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
104 <title>Admin</title>
105 <script>
106 try {
107 var t = localStorage.getItem("gize-admin-theme");
108 if (t === "dark" || t === "light") document.documentElement.setAttribute("data-theme", t);
109 } catch (e) {}
110 </script>
111 </head>
112 <body>
113 <div id="root"></div>
114 <script type="module" src="/src/main.tsx"></script>
115 </body>
116</html>
117"####
118 .to_string()
119}
120
121pub fn env_example() -> String {
123 "# Point at your running Gize backend (proxied under /api in dev).\nVITE_API_URL=http://localhost:8080\n"
124 .to_string()
125}
126
127pub fn gitignore() -> String {
129 "node_modules\ndist\n.env\n".to_string()
130}
131
132pub fn styles_css() -> String {
134 include_str!("assets/styles.css").to_string()
135}
136
137pub fn main_tsx() -> String {
139 r#"import React from "react";
140import { createRoot } from "react-dom/client";
141import App from "./App";
142import "./styles.css";
143
144createRoot(document.getElementById("root")!).render(
145 <React.StrictMode>
146 <App />
147 </React.StrictMode>,
148);
149"#
150 .to_string()
151}
152
153pub fn theme_ts() -> String {
155 r#"export type Theme = "light" | "dark";
156
157export function currentTheme(): Theme {
158 const stored = safeGet();
159 if (stored === "light" || stored === "dark") return stored;
160 return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
161}
162
163export function setTheme(theme: Theme): void {
164 document.documentElement.setAttribute("data-theme", theme);
165 try {
166 localStorage.setItem("gize-admin-theme", theme);
167 } catch {
168 /* ignore */
169 }
170}
171
172export function toggleTheme(): Theme {
173 const next: Theme = currentTheme() === "dark" ? "light" : "dark";
174 setTheme(next);
175 return next;
176}
177
178function safeGet(): string | null {
179 try {
180 return localStorage.getItem("gize-admin-theme");
181 } catch {
182 return null;
183 }
184}
185"#
186 .to_string()
187}
188
189pub fn icons_tsx() -> String {
191 include_str!("assets/icons.tsx").to_string()
192}
193
194pub fn toast_tsx() -> String {
196 r#"import { useEffect, useState } from "react";
197import { Icon } from "./icons";
198
199export function toast(message: string): void {
200 window.dispatchEvent(new CustomEvent("gize-toast", { detail: message }));
201}
202
203export function ToastHost() {
204 const [msg, setMsg] = useState<string | null>(null);
205 useEffect(() => {
206 let timer: number | undefined;
207 function onToast(e: Event) {
208 setMsg((e as CustomEvent<string>).detail);
209 window.clearTimeout(timer);
210 timer = window.setTimeout(() => setMsg(null), 2400);
211 }
212 window.addEventListener("gize-toast", onToast);
213 return () => {
214 window.removeEventListener("gize-toast", onToast);
215 window.clearTimeout(timer);
216 };
217 }, []);
218 return (
219 <div className={"toast" + (msg ? " show" : "")} role="status" aria-live="polite">
220 <Icon name="check" className="ok" />
221 <span>{msg}</span>
222 </div>
223 );
224}
225"#
226 .to_string()
227}
228
229pub fn api_ts() -> String {
231 r#"const BASE = "/api";
232
233export function getToken(): string | null {
234 return localStorage.getItem("gize_token");
235}
236
237async function req(path: string, opts: RequestInit = {}): Promise<any> {
238 const headers: Record<string, string> = {
239 "content-type": "application/json",
240 ...(opts.headers as Record<string, string>),
241 };
242 const t = getToken();
243 if (t) headers["authorization"] = `Bearer ${t}`;
244 const res = await fetch(`${BASE}${path}`, { ...opts, headers });
245 if (!res.ok) {
246 const body = await res.text();
247 throw new Error(body || String(res.status));
248 }
249 if (res.status === 204) return null;
250 return res.json();
251}
252
253export const api = {
254 list: (path: string): Promise<any[]> => req(`/${path}`),
255 get: (path: string, id: string) => req(`/${path}/${id}`),
256 create: (path: string, body: unknown) =>
257 req(`/${path}`, { method: "POST", body: JSON.stringify(body) }),
258 update: (path: string, id: string, body: unknown) =>
259 req(`/${path}/${id}`, { method: "PUT", body: JSON.stringify(body) }),
260 remove: (path: string, id: string) => req(`/${path}/${id}`, { method: "DELETE" }),
261 login: (email: string, password: string) =>
262 req(`/users/login`, { method: "POST", body: JSON.stringify({ email, password }) }),
263};
264
265/** A resource's display label: the (plural) path, capitalized — e.g. "posts" -> "Posts". */
266export function titleCase(s: string): string {
267 return s.charAt(0).toUpperCase() + s.slice(1);
268}
269
270/** A readable label for a related record (used by foreign-key selects and cells). */
271export function labelOf(row: Record<string, any>): string {
272 for (const key of ["name", "title", "email", "slug"]) {
273 if (row[key]) return String(row[key]);
274 }
275 for (const key of Object.keys(row)) {
276 const value = row[key];
277 if (typeof value === "string" && key !== "id" && !key.endsWith("_at")) return value;
278 }
279 return String(row.id ?? "");
280}
281"#
282 .to_string()
283}
284
285pub fn auth_tsx() -> String {
287 include_str!("assets/auth.tsx").to_string()
288}
289
290pub fn app_tsx() -> String {
292 include_str!("assets/App.tsx").to_string()
293}
294
295pub fn resource_tsx() -> String {
297 include_str!("assets/Resource.tsx").to_string()
298}
299
300pub fn resource_form_tsx() -> String {
302 include_str!("assets/ResourceForm.tsx").to_string()
303}
304
305pub fn resources_ts(manifest: &Manifest) -> anyhow::Result<String> {
309 let mut entries = String::new();
310 for module in &manifest.modules {
311 let model = module.model_spec()?;
312 let table = table_name(&model.name);
313
314 let mut fields_ts = String::new();
315 let mut schema_ts = String::new();
316 for f in &model.fields {
317 let kind = field_kind(&f.name, f.ty);
318 let ref_target = model
320 .relations
321 .iter()
322 .find(|r| r.fk_column() == f.name)
323 .map(|r| r.target.clone());
324 let ref_ts = match &ref_target {
325 Some(t) => format!(", ref: \"{t}\""),
326 None => String::new(),
327 };
328 fields_ts.push_str(&format!(
329 " {{ name: \"{}\", kind: \"{kind}\"{ref_ts} }},\n",
330 f.name
331 ));
332 schema_ts.push_str(&format!(" {}: {},\n", f.name, zod_for(kind)));
333 }
334
335 entries.push_str(&format!(
336 " {{\n name: \"{name}\",\n path: \"{table}\",\n fields: [\n{fields_ts} ],\n createSchema: z.object({{\n{schema_ts} }}),\n }},\n",
337 name = model.name,
338 ));
339 }
340
341 Ok(format!(
342 r#"import {{ z }} from "zod";
343
344export type FieldKind =
345 | "string"
346 | "number"
347 | "boolean"
348 | "uuid"
349 | "datetime"
350 | "email"
351 | "password";
352
353export interface FieldDesc {{
354 name: string;
355 kind: FieldKind;
356 /** For a foreign key: the target resource path (e.g. "users"). */
357 ref?: string;
358}}
359
360export interface ResourceDesc {{
361 name: string;
362 path: string;
363 fields: FieldDesc[];
364 createSchema: z.ZodTypeAny;
365}}
366
367export const resources: ResourceDesc[] = [
368{entries}];
369"#
370 ))
371}
372
373fn field_kind(name: &str, ty: FieldType) -> &'static str {
376 match name {
377 "email" => "email",
378 "password" => "password",
379 _ => match ty {
380 FieldType::String => "string",
381 FieldType::Bool => "boolean",
382 FieldType::I32 | FieldType::I64 | FieldType::F64 => "number",
383 FieldType::Uuid => "uuid",
384 FieldType::DateTime => "datetime",
385 },
386 }
387}
388
389fn zod_for(kind: &str) -> &'static str {
391 match kind {
392 "email" => "z.string().email()",
393 "password" => "z.string().min(8)",
394 "number" => "z.coerce.number()",
395 "boolean" => "z.boolean()",
396 "uuid" => "z.string().uuid()",
397 "datetime" => "z.string().min(1)",
398 _ => "z.string().min(1)",
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use gize_core::{Module, Relation};
406
407 fn manifest() -> Manifest {
408 let mut m = Manifest::new("blog");
409 m.upsert_module(Module {
410 name: "users".to_string(),
411 fields: vec![
412 "name:String".to_string(),
413 "email:String".to_string(),
414 "password:String".to_string(),
415 "is_admin:bool".to_string(),
416 ],
417 belongs_to: vec![],
418 });
419 m.upsert_module(Module {
420 name: "posts".to_string(),
421 fields: vec!["title:String".to_string()],
422 belongs_to: vec![Relation {
423 field: "author".to_string(),
424 target: "users".to_string(),
425 }],
426 });
427 m
428 }
429
430 #[test]
431 fn resources_ts_describes_each_resource() {
432 let ts = resources_ts(&manifest()).unwrap();
433 assert!(ts.contains("path: \"users\""));
434 assert!(ts.contains("path: \"posts\""));
435 assert!(ts.contains("{ name: \"email\", kind: \"email\" }"));
436 assert!(ts.contains("email: z.string().email()"));
437 assert!(ts.contains("password: z.string().min(8)"));
438 assert!(ts.contains("{ name: \"author_id\", kind: \"uuid\", ref: \"users\" }"));
440 }
441
442 #[test]
443 fn package_json_uses_the_project_name() {
444 assert!(package_json("blog").contains("\"name\": \"blog-admin\""));
445 }
446}