use gize_core::naming::table_name;
use gize_core::{FieldType, Manifest};
pub fn package_json(project: &str) -> String {
format!(
r#"{{
"name": "{project}-admin",
"private": true,
"type": "module",
"scripts": {{
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
}},
"dependencies": {{
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.59.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"zod": "^3.23.8"
}},
"devDependencies": {{
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}},
"pnpm": {{
"onlyBuiltDependencies": ["esbuild"]
}}
}}
"#
)
}
pub fn vite_config() -> String {
r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// The API base is proxied so the browser talks same-origin in dev. Set VITE_API_URL to point
// at your running Gize backend (default http://localhost:8080).
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: process.env.VITE_API_URL || "http://localhost:8080",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ""),
},
},
},
});
"#
.to_string()
}
pub fn tsconfig() -> String {
r#"{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
"#
.to_string()
}
pub fn index_html() -> String {
r####"<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
<script>
try {
var t = localStorage.getItem("gize-admin-theme");
if (t === "dark" || t === "light") document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
"####
.to_string()
}
pub fn env_example() -> String {
"# Point at your running Gize backend (proxied under /api in dev).\nVITE_API_URL=http://localhost:8080\n"
.to_string()
}
pub fn gitignore() -> String {
"node_modules\ndist\n.env\n".to_string()
}
pub fn styles_css() -> String {
include_str!("assets/styles.css").to_string()
}
pub fn main_tsx() -> String {
r#"import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
"#
.to_string()
}
pub fn theme_ts() -> String {
r#"export type Theme = "light" | "dark";
export function currentTheme(): Theme {
const stored = safeGet();
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function setTheme(theme: Theme): void {
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("gize-admin-theme", theme);
} catch {
/* ignore */
}
}
export function toggleTheme(): Theme {
const next: Theme = currentTheme() === "dark" ? "light" : "dark";
setTheme(next);
return next;
}
function safeGet(): string | null {
try {
return localStorage.getItem("gize-admin-theme");
} catch {
return null;
}
}
"#
.to_string()
}
pub fn icons_tsx() -> String {
include_str!("assets/icons.tsx").to_string()
}
pub fn toast_tsx() -> String {
r#"import { useEffect, useState } from "react";
import { Icon } from "./icons";
export function toast(message: string): void {
window.dispatchEvent(new CustomEvent("gize-toast", { detail: message }));
}
export function ToastHost() {
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => {
let timer: number | undefined;
function onToast(e: Event) {
setMsg((e as CustomEvent<string>).detail);
window.clearTimeout(timer);
timer = window.setTimeout(() => setMsg(null), 2400);
}
window.addEventListener("gize-toast", onToast);
return () => {
window.removeEventListener("gize-toast", onToast);
window.clearTimeout(timer);
};
}, []);
return (
<div className={"toast" + (msg ? " show" : "")} role="status" aria-live="polite">
<Icon name="check" className="ok" />
<span>{msg}</span>
</div>
);
}
"#
.to_string()
}
pub fn api_ts() -> String {
r#"const BASE = "/api";
export function getToken(): string | null {
return localStorage.getItem("gize_token");
}
async function req(path: string, opts: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = {
"content-type": "application/json",
...(opts.headers as Record<string, string>),
};
const t = getToken();
if (t) headers["authorization"] = `Bearer ${t}`;
const res = await fetch(`${BASE}${path}`, { ...opts, headers });
if (!res.ok) {
const body = await res.text();
throw new Error(body || String(res.status));
}
if (res.status === 204) return null;
return res.json();
}
/** Largest page the API serves (ADR-022). */
const PER_PAGE = 100;
/** Every row of a resource: pages of PER_PAGE until the last one, so search, filters and
* foreign-key selects can keep working on the full list in the browser. */
async function listAll(path: string): Promise<any[]> {
const rows: any[] = [];
for (let page = 1; ; page++) {
const res = await req(`/${path}?page=${page}&per_page=${PER_PAGE}`);
rows.push(...res.items);
if (res.items.length < PER_PAGE || rows.length >= res.total) return rows;
}
}
export const api = {
list: listAll,
get: (path: string, id: string) => req(`/${path}/${id}`),
create: (path: string, body: unknown) =>
req(`/${path}`, { method: "POST", body: JSON.stringify(body) }),
update: (path: string, id: string, body: unknown) =>
req(`/${path}/${id}`, { method: "PUT", body: JSON.stringify(body) }),
remove: (path: string, id: string) => req(`/${path}/${id}`, { method: "DELETE" }),
login: (email: string, password: string) =>
req(`/users/login`, { method: "POST", body: JSON.stringify({ email, password }) }),
};
/** A resource's display label: the (plural) path in words, capitalized — e.g.
* "posts" -> "Posts", "ticket_types" -> "Ticket types". */
export function titleCase(s: string): string {
const words = s.replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
/** A model name as a lowercase noun for buttons and dialogs — e.g. "TicketType" -> "ticket type". */
export function nounOf(model: string): string {
return model.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
}
/** A readable label for a related record (used by foreign-key selects and cells). */
export function labelOf(row: Record<string, any>): string {
for (const key of ["name", "title", "email", "slug"]) {
if (row[key]) return String(row[key]);
}
for (const key of Object.keys(row)) {
const value = row[key];
if (typeof value === "string" && key !== "id" && !key.endsWith("_at")) return value;
}
return String(row.id ?? "");
}
"#
.to_string()
}
pub fn auth_tsx() -> String {
include_str!("assets/auth.tsx").to_string()
}
pub fn app_tsx() -> String {
include_str!("assets/App.tsx").to_string()
}
pub fn resource_tsx() -> String {
include_str!("assets/Resource.tsx").to_string()
}
pub fn resource_form_tsx() -> String {
include_str!("assets/ResourceForm.tsx").to_string()
}
pub fn resources_ts(manifest: &Manifest) -> anyhow::Result<String> {
let mut entries = String::new();
for module in &manifest.modules {
let model = module.model_spec()?;
let table = table_name(&model.name);
let mut fields_ts = String::new();
let mut schema_ts = String::new();
for f in &model.fields {
let kind = field_kind(&f.name, f.ty);
let ref_target = model
.relations
.iter()
.find(|r| r.fk_column() == f.name)
.map(|r| r.target.clone());
let ref_ts = match &ref_target {
Some(t) => format!(", ref: \"{t}\""),
None => String::new(),
};
fields_ts.push_str(&format!(
" {{ name: \"{}\", kind: \"{kind}\"{ref_ts} }},\n",
f.name
));
let zod = if ref_target.is_some() {
"z.string().uuid(\"Choose one\")"
} else {
zod_for(kind)
};
schema_ts.push_str(&format!(" {}: {zod},\n", f.name));
}
entries.push_str(&format!(
" {{\n name: \"{name}\",\n path: \"{table}\",\n fields: [\n{fields_ts} ],\n createSchema: z.object({{\n{schema_ts} }}),\n }},\n",
name = model.name,
));
}
Ok(format!(
r#"import {{ z }} from "zod";
export type FieldKind =
| "string"
| "number"
| "boolean"
| "uuid"
| "datetime"
| "email"
| "password";
export interface FieldDesc {{
name: string;
kind: FieldKind;
/** For a foreign key: the target resource path (e.g. "users"). */
ref?: string;
}}
export interface ResourceDesc {{
name: string;
path: string;
fields: FieldDesc[];
createSchema: z.ZodTypeAny;
}}
export const resources: ResourceDesc[] = [
{entries}];
"#
))
}
fn field_kind(name: &str, ty: FieldType) -> &'static str {
match name {
"email" => "email",
"password" => "password",
_ => match ty {
FieldType::String => "string",
FieldType::Bool => "boolean",
FieldType::I32 | FieldType::I64 | FieldType::F64 => "number",
FieldType::Uuid => "uuid",
FieldType::DateTime => "datetime",
},
}
}
fn zod_for(kind: &str) -> &'static str {
match kind {
"email" => "z.string().email()",
"password" => "z.string().min(8)",
"number" => "z.coerce.number()",
"boolean" => "z.boolean()",
"uuid" => "z.string().uuid()",
"datetime" => "z.string().min(1)",
_ => "z.string().min(1)",
}
}
#[cfg(test)]
mod tests {
use super::*;
use gize_core::{Module, Relation};
fn manifest() -> Manifest {
let mut m = Manifest::new("blog");
m.upsert_module(Module {
name: "users".to_string(),
model: None,
fields: vec![
"name:String".to_string(),
"email:String".to_string(),
"password:String".to_string(),
"is_admin:bool".to_string(),
],
belongs_to: vec![],
});
m.upsert_module(Module {
name: "posts".to_string(),
model: None,
fields: vec!["title:String".to_string()],
belongs_to: vec![Relation {
field: "author".to_string(),
target: "users".to_string(),
}],
});
m
}
#[test]
fn resources_ts_describes_each_resource() {
let ts = resources_ts(&manifest()).unwrap();
assert!(ts.contains("path: \"users\""));
assert!(ts.contains("path: \"posts\""));
assert!(ts.contains("{ name: \"email\", kind: \"email\" }"));
assert!(ts.contains("email: z.string().email()"));
assert!(ts.contains("password: z.string().min(8)"));
assert!(ts.contains("{ name: \"author_id\", kind: \"uuid\", ref: \"users\" }"));
}
#[test]
fn api_client_walks_every_page() {
let out = api_ts();
assert!(out.contains("const PER_PAGE = 100;"));
assert!(out.contains("?page=${page}&per_page=${PER_PAGE}"));
assert!(out.contains("list: listAll,"));
}
#[test]
fn labels_read_as_words_and_foreign_keys_explain_themselves() {
let api = api_ts();
assert!(api.contains(r#"s.replace(/_/g, " ")"#));
assert!(api.contains("export function nounOf(model: string)"));
let ts = resources_ts(&manifest()).unwrap();
assert!(ts.contains(r#"author_id: z.string().uuid("Choose one"),"#));
}
#[test]
fn package_json_uses_the_project_name() {
assert!(package_json("blog").contains("\"name\": \"blog-admin\""));
}
#[test]
fn package_json_approves_esbuild_build_so_serve_works_without_pnpm_approve_builds() {
assert!(package_json("blog").contains("\"onlyBuiltDependencies\": [\"esbuild\"]"));
}
}