UNPKG

4bnode

Version:

4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.

1,232 lines (1,193 loc) 59.1 kB
import React, { useState, useEffect, useRef } from "react"; import { LuPlug } from "react-icons/lu"; import { html, api, Icons } from "../lib.js"; import { useApp } from "../context.js"; export function RoutesPage() { const { data, toast, refresh, showConfirm } = useApp(); const [showCreate, setShowCreate] = useState(false); const [selectedRoute, setSelectedRoute] = useState(null); const [routeContents, setRouteContents] = useState({}); const [loadingRoute, setLoadingRoute] = useState(null); const [showAddMethod, setShowAddMethod] = useState(false); const [amRouteType, setAmRouteType] = useState(null); const [amMethods, setAmMethods] = useState([]); const [amModelName, setAmModelName] = useState(""); const [amLoginField, setAmLoginField] = useState(""); const [amPasswordField, setAmPasswordField] = useState(""); const [amRegisterFields, setAmRegisterFields] = useState([]); const [amSelectedFields, setAmSelectedFields] = useState([]); const [amCustomFields, setAmCustomFields] = useState([]); const [amNewFieldName, setAmNewFieldName] = useState(""); const [amNewFieldType, setAmNewFieldType] = useState("String"); const [amLoading, setAmLoading] = useState(false); // Auto-select first route useEffect(() => { if (data.routes?.length > 0 && !selectedRoute && !showCreate) { selectRoute(data.routes[0]); } }, [data.routes]); async function selectRoute(filename) { setShowCreate(false); setSelectedRoute(filename); if (!routeContents[filename]) { setLoadingRoute(filename); try { const result = await api("/routes/" + filename + "/content"); setRouteContents((prev) => ({ ...prev, [filename]: result })); } catch (err) { toast(err.message, "error"); } setLoadingRoute(null); } } function parseEndpoints(content) { if (!content) return []; const endpoints = []; const regex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi; let m; while ((m = regex.exec(content)) !== null) { endpoints.push({ method: m[1].toUpperCase(), path: m[2] }); } return endpoints; } function getMethodsForFile(filename) { const content = routeContents[filename]?.content; if (!content) return []; const methods = []; const regex = /router\.(get|post|put|patch|delete)\(/gi; let m; while ((m = regex.exec(content)) !== null) { const method = m[1].toUpperCase(); if (!methods.includes(method)) methods.push(method); } return methods; } const selName = selectedRoute?.replace(".js", "") || ""; const selContent = routeContents[selectedRoute]?.content; const selEndpoints = parseEndpoints(selContent); const hasAuth = selContent?.includes("import auth") || false; const hasModel = selContent?.match( /import\s+(\w+)\s+from\s+'\.\.\/models/, ); // When no routes yet, show empty + create in a simpler layout if (data.routes?.length === 0 && !showCreate) { return html`<div> <div className="flex items-center justify-between mb-4"> <div> <div className="card-title">API Routes</div> <div className="card-subtitle">0 route files</div> </div> <button className="btn btn-primary" onClick=${() => setShowCreate(true)} > <span className="sidebar-icon">${Icons.plus}</span> New Route </button> </div> <div className="empty-state"> <div className="empty-icon"><${LuPlug} size=${36} /></div> <h3>No routes yet</h3> <p>Create your first API endpoint to get started</p> <button className="btn btn-primary" onClick=${() => setShowCreate(true)} > Create Route </button> </div> </div>`; } return html`<div> <div className="flex items-center justify-between mb-4"> <div> <div className="card-title">API Routes</div> <div className="card-subtitle"> ${data.routes?.length || 0} route file${data.routes?.length !== 1 ? "s" : ""} </div> </div> <button className=${`btn ${showCreate ? "btn-ghost" : "btn-primary"}`} onClick=${() => { if (showCreate) { setShowCreate(false); if (data.routes?.length) selectRoute(data.routes[0]); } else { setShowCreate(true); setSelectedRoute(null); } }} > ${showCreate ? "Cancel" : html`<span className="sidebar-icon">${Icons.plus}</span> New Route`} </button> </div> <div className="routes-layout"> <div className="card" style=${{ padding: 0, overflow: "hidden" }}> <div style=${{ padding: "14px 16px 10px", borderBottom: "1px solid var(--border)", }} > <span style=${{ fontWeight: 600, fontSize: "12px", textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", }} >Files</span > </div> <div className="routes-list"> ${(data.routes || []).map((r) => { const rName = r.replace(".js", ""); const methods = getMethodsForFile(r); const isActive = selectedRoute === r && !showCreate; return html`<div key=${r} className=${`route-item ${isActive ? "active" : ""}`} onClick=${() => selectRoute(r)} > <div style=${{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0, }} > <span className="ri-name">${rName}</span> <span className="ri-file">src/routes/${r}</span> </div> <div className="flex gap-1" style=${{ flexWrap: "wrap" }}> ${methods.length > 0 ? methods.map( (m) => html`<span key=${m} className=${`route-method ${m.toLowerCase()}`} >${m}</span >`, ) : html`<span className="route-method multi">...</span>`} </div> </div>`; })} <div className=${`route-item ${showCreate ? "active" : ""}`} onClick=${() => { setShowCreate(true); setSelectedRoute(null); }} style=${{ borderTop: data.routes?.length ? "1px solid var(--border)" : "none", }} > <span className="sidebar-icon" style=${{ color: "var(--accent-light)" }} >${Icons.plus}</span > <span className="ri-name" style=${{ color: "var(--accent-light)" }} >New Route</span > </div> </div> </div> <div className="routes-detail"> ${showCreate && html`<${CreateRoutePanel} onCreated=${() => { setShowCreate(false); refresh(); }} />`} ${!showCreate && !selectedRoute && html`<div className="card"> <div className="empty-state" style=${{ padding: "40px" }}> <p>Select a route to view details</p> </div> </div>`} ${!showCreate && selectedRoute && html`<div> <div className="card mb-4"> <div className="flex items-center justify-between mb-3"> <div> <div style=${{ fontWeight: 600, fontSize: "16px", marginBottom: "2px", }} > ${selName} </div> <div className="text-sm text-muted" style=${{ fontFamily: "'SF Mono', 'Fira Code', monospace", }} > src/routes/${selectedRoute} </div> </div> <div className="flex gap-2 items-center"> ${hasAuth && html`<span className="badge badge-warning">Auth</span>`} ${hasModel && html`<span className="badge badge-info" >Model: ${hasModel[1]}</span >`} <button className=${`btn btn-sm ${showAddMethod ? 'btn-primary' : 'btn-ghost'}`} onClick=${() => { setShowAddMethod(!showAddMethod); setAmRouteType(null); setAmMethods([]); setAmModelName(""); setAmLoginField(""); setAmPasswordField(""); setAmRegisterFields([]); setAmSelectedFields([]); setAmCustomFields([]); }} > ${showAddMethod ? "Cancel" : html`<span className="sidebar-icon">${Icons.plus}</span> Add`} </button> <button className="btn btn-danger btn-sm btn-icon" onClick=${async () => { const ok = await showConfirm('Delete route "' + selName + '"? This will remove the route file and its registration from index.js.', { danger: true, confirmLabel: 'Delete Route' }); if (!ok) return; try { await api("/routes/" + selectedRoute, { method: "DELETE", }); toast('Route "' + selName + '" deleted', "success"); setSelectedRoute(null); setRouteContents((prev) => { const n = { ...prev }; delete n[selectedRoute]; return n; }); refresh(); } catch (err) { toast(err.message, "error"); } }} > <span className="sidebar-icon">${Icons.trash}</span> </button> </div> </div> ${selEndpoints.length > 0 && html`<div> <div style=${{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px", }} > Endpoints </div> ${selEndpoints.map( (ep, i) => html`<div className="route-endpoint-row" key=${i}> <span className=${`route-method ${ep.method.toLowerCase()}`} >${ep.method}</span > <span className="route-endpoint-path" >/api/${selName}${ep.path === "/" ? "" : ep.path}</span > </div>`, )} </div>`} </div> ${(() => { const amNeedsModel = amRouteType && amRouteType !== "methods" && amRouteType !== "plain"; const amIsAuthType = amRouteType === "login" || amRouteType === "register"; const amModelObj = data.models?.find(m => m.name === amModelName); const amModelFields = amModelObj?.fields || []; const amMethodColors = { get: "#22c55e", post: "#3b82f6", put: "#f59e0b", patch: "#a855f7", delete: "#ef4444" }; const existingMethods = selEndpoints.map(ep => ep.method.toLowerCase()); const amCanSubmit = amRouteType && (amRouteType !== "methods" || amMethods.length > 0) && (amRouteType !== "crud" || amMethods.length > 0) && (!amNeedsModel || amModelName) && (!amIsAuthType || amLoginField); return showAddMethod && html`<div className="card mb-4"> <div style=${{ fontSize: "15px", fontWeight: 600, marginBottom: "14px" }}>Add to ${selName}</div> <div className="form-group"> <label className="form-label">Type</label> <div style=${{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "6px" }}> ${[ { id: "methods", label: "Methods", desc: "GET, POST, PUT...", color: "var(--accent-light)" }, { id: "crud", label: "CRUD", desc: "Model CRUD ops", color: "#3b82f6" }, { id: "login", label: "Login", desc: "JWT login", color: "#22d3ee" }, { id: "register", label: "Register", desc: "Sign up", color: "#34d399" }, ].map(t => { const active = amRouteType === t.id; return html`<button key=${t.id} style=${{ padding: "8px 4px", borderRadius: "var(--radius)", cursor: "pointer", textAlign: "center", border: active ? "1.5px solid " + t.color : "1.5px solid var(--border)", background: active ? "rgba(255,255,255,0.04)" : "var(--bg-input)", opacity: active ? 1 : 0.5, transition: "all 0.15s", }} onClick=${() => { setAmRouteType(prev => prev === t.id ? null : t.id); if (t.id !== "methods" && t.id !== "crud") setAmMethods([]); }}> <div style=${{ fontSize: "11px", fontWeight: 700, color: active ? t.color : "var(--text-secondary)" }}>${t.label}</div> <div style=${{ fontSize: "9px", color: "var(--text-muted)", marginTop: "1px" }}>${t.desc}</div> </button>`; })} </div> </div> ${amRouteType === "methods" && html`<div className="form-group"> <label className="form-label">HTTP Methods</label> <div className="flex gap-2"> ${["get", "post", "put", "patch", "delete"].map(m => html`<button key=${m} className=${`route-method ${m}`} style=${{ cursor: existingMethods.includes(m) ? "not-allowed" : "pointer", border: amMethods.includes(m) ? "2px solid " + amMethodColors[m] : "2px solid transparent", padding: "6px 12px", fontSize: "11px", opacity: existingMethods.includes(m) ? 0.15 : amMethods.includes(m) ? 1 : 0.35, borderRadius: "6px", transition: "all 0.15s", textDecoration: existingMethods.includes(m) ? "line-through" : "none", }} disabled=${existingMethods.includes(m)} onClick=${() => !existingMethods.includes(m) && setAmMethods(prev => prev.includes(m) ? prev.filter(x => x !== m) : [...prev, m])} >${m.toUpperCase()}</button>`)} </div> </div>`} ${amRouteType === "crud" && html`<div className="form-group"> <label className="form-label">CRUD Methods <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(select operations)</span></label> <div className="flex gap-2"> ${["get", "post", "put", "patch", "delete"].map(m => { const labels = { get: "READ", post: "CREATE", put: "UPDATE", patch: "PATCH", delete: "DELETE" }; return html`<button key=${m} className=${`route-method ${m}`} style=${{ cursor: existingMethods.includes(m) ? "not-allowed" : "pointer", border: amMethods.includes(m) ? "2px solid " + amMethodColors[m] : "2px solid transparent", padding: "6px 12px", fontSize: "11px", opacity: existingMethods.includes(m) ? 0.15 : amMethods.includes(m) ? 1 : 0.35, borderRadius: "6px", transition: "all 0.15s", textDecoration: existingMethods.includes(m) ? "line-through" : "none", }} disabled=${existingMethods.includes(m)} onClick=${() => !existingMethods.includes(m) && setAmMethods(prev => prev.includes(m) ? prev.filter(x => x !== m) : [...prev, m])} >${labels[m]}</button>`; })} </div> </div>`} ${amNeedsModel && html`<div className="form-group"> <label className="form-label">Model</label> <select className="form-select" value=${amModelName} onChange=${(e) => { setAmModelName(e.target.value); setAmLoginField(""); setAmPasswordField(""); setAmRegisterFields([]); }}> <option value="">Select a model...</option> ${(data.models || []).map(m => html`<option key=${m.name} value=${m.name}>${m.name}</option>`)} </select> </div>`} ${amRouteType === "methods" && html`<div className="form-group"> <label className="form-label">Fields from Model <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(optional)</span></label> ${data.models?.length > 0 ? html`<select className="form-select" style=${{ marginBottom: "8px" }} value=${amModelName} onChange=${(e) => { setAmModelName(e.target.value); setAmSelectedFields([]); }}> <option value="">Select a model...</option> ${data.models.map(m => html`<option key=${m.name} value=${m.name}>${m.name}</option>`)} </select> ${amModelName && amModelFields.length > 0 && html`<div className="flex gap-2" style=${{ flexWrap: "wrap" }}> ${amModelFields.map(f => { const active = amSelectedFields.includes(f.name); return html`<button key=${f.name} style=${{ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", border: active ? "1.5px solid var(--accent)" : "1.5px solid var(--border)", background: active ? "var(--accent-glow)" : "var(--bg-input)", color: active ? "var(--accent-light)" : "var(--text-secondary)", fontWeight: active ? 600 : 400, transition: "all 0.15s", }} onClick=${() => setAmSelectedFields(prev => active ? prev.filter(x => x !== f.name) : [...prev, f.name])}> ${f.name} <span style=${{ fontSize: "10px", color: "var(--text-muted)", marginLeft: "4px" }}>${f.type}</span> </button>`; })} </div>`}` : html`<div style=${{ fontSize: "11px", color: "var(--text-muted)" }}>No models found.</div>`} </div>`} ${amRouteType === "login" && amModelName && amModelFields.length > 0 && html`<div className="form-group"> <label className="form-label">Find user by</label> <div className="flex gap-2" style=${{ flexWrap: "wrap" }}> ${amModelFields.map(f => html`<button key=${f.name} style=${{ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", border: amLoginField === f.name ? "1.5px solid var(--accent)" : "1.5px solid var(--border)", background: amLoginField === f.name ? "var(--accent-glow)" : "var(--bg-input)", color: amLoginField === f.name ? "var(--accent-light)" : "var(--text-secondary)", fontWeight: amLoginField === f.name ? 600 : 400, transition: "all 0.15s", }} onClick=${() => setAmLoginField(f.name)}>${f.name}</button>`)} </div> </div>`} ${amIsAuthType && amModelName && amModelFields.length > 0 && html`<div className="form-group"> <label className="form-label">Password field <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(optional — enables bcrypt)</span></label> <div className="flex gap-2" style=${{ flexWrap: "wrap" }}> <button style=${{ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", border: !amPasswordField ? "1.5px solid var(--accent)" : "1.5px solid var(--border)", background: !amPasswordField ? "var(--accent-glow)" : "var(--bg-input)", color: !amPasswordField ? "var(--accent-light)" : "var(--text-secondary)", fontWeight: !amPasswordField ? 600 : 400, transition: "all 0.15s", }} onClick=${() => setAmPasswordField("")}>None</button> ${amModelFields.map(f => html`<button key=${f.name} style=${{ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", border: amPasswordField === f.name ? "1.5px solid var(--warning)" : "1.5px solid var(--border)", background: amPasswordField === f.name ? "var(--warning-glow)" : "var(--bg-input)", color: amPasswordField === f.name ? "var(--warning)" : "var(--text-secondary)", fontWeight: amPasswordField === f.name ? 600 : 400, transition: "all 0.15s", }} onClick=${() => setAmPasswordField(f.name)}>${f.name}</button>`)} </div> </div>`} ${amRouteType === "register" && amModelName && amModelFields.length > 0 && html`<div className="form-group"> <label className="form-label">Registration fields</label> <div className="flex gap-2" style=${{ flexWrap: "wrap" }}> ${amModelFields.map(f => { const active = amRegisterFields.includes(f.name); return html`<button key=${f.name} style=${{ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", border: active ? "1.5px solid var(--success)" : "1.5px solid var(--border)", background: active ? "var(--success-glow)" : "var(--bg-input)", color: active ? "var(--success)" : "var(--text-secondary)", fontWeight: active ? 600 : 400, transition: "all 0.15s", }} onClick=${() => setAmRegisterFields(prev => active ? prev.filter(x => x !== f.name) : [...prev, f.name])}>${f.name}</button>`; })} </div> </div>`} ${amRouteType === "register" && amModelName && amModelFields.length > 0 && html`<div className="form-group"> <label className="form-label">Check duplicate by</label> <div className="flex gap-2" style=${{ flexWrap: "wrap" }}> ${amModelFields.map(f => html`<button key=${f.name} style=${{ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", border: amLoginField === f.name ? "1.5px solid var(--accent)" : "1.5px solid var(--border)", background: amLoginField === f.name ? "var(--accent-glow)" : "var(--bg-input)", color: amLoginField === f.name ? "var(--accent-light)" : "var(--text-secondary)", fontWeight: amLoginField === f.name ? 600 : 400, transition: "all 0.15s", }} onClick=${() => setAmLoginField(f.name)}>${f.name}</button>`)} </div> </div>`} ${amRouteType === "methods" && html`<div className="form-group"> <label className="form-label">Custom Fields <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(from client)</span></label> ${amCustomFields.length > 0 && html`<div style=${{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "8px" }}> ${amCustomFields.map((f, i) => html`<span key=${i} style=${{ padding: "4px 10px", borderRadius: "6px", fontSize: "12px", background: "var(--bg-input)", border: "1.5px solid var(--border)", color: "var(--text-primary)", display: "flex", alignItems: "center", gap: "6px", }}> ${f.name} <span style=${{ fontSize: "10px", color: f.type === "File" ? "var(--warning)" : "var(--text-muted)" }}>${f.type}</span> <span style=${{ cursor: "pointer", color: "var(--text-muted)", fontSize: "14px", lineHeight: 1 }} onClick=${() => setAmCustomFields(prev => prev.filter((_, idx) => idx !== i))}>×</span> </span>`)} </div>`} <div className="flex gap-2" style=${{ alignItems: "center" }}> <input className="form-input" value=${amNewFieldName} onInput=${(e) => setAmNewFieldName(e.target.value)} placeholder="Field name" style=${{ fontSize: "13px", padding: "8px 12px", flex: 1 }} onKeyDown=${(e) => { if (e.key === "Enter" && amNewFieldName.trim()) { setAmCustomFields(prev => [...prev, { name: amNewFieldName.trim(), type: amNewFieldType }]); setAmNewFieldName(""); }}} /> <select className="form-select" value=${amNewFieldType} onChange=${(e) => setAmNewFieldType(e.target.value)} style=${{ width: "auto", minWidth: "100px" }}> <option value="String">String</option><option value="Number">Number</option><option value="Boolean">Boolean</option> <option value="Date">Date</option><option value="Array">Array</option><option value="Object">Object</option><option value="File">File</option> </select> <button className="btn btn-sm" style=${{ padding: "8px 14px", fontSize: "12px", background: "var(--bg-input)", border: "1.5px solid var(--border)", color: "var(--text-primary)", cursor: amNewFieldName.trim() ? "pointer" : "not-allowed", opacity: amNewFieldName.trim() ? 1 : 0.4, borderRadius: "6px", }} disabled=${!amNewFieldName.trim()} onClick=${() => { if (amNewFieldName.trim()) { setAmCustomFields(prev => [...prev, { name: amNewFieldName.trim(), type: amNewFieldType }]); setAmNewFieldName(""); } }}>+ Add</button> </div> </div>`} ${amCanSubmit && html`<button className="btn btn-primary" disabled=${amLoading} onClick=${async () => { setAmLoading(true); try { const result = await api("/routes/" + selectedRoute + "/add-method", { method: "POST", body: { routeType: amRouteType, methods: (amRouteType === "methods" || amRouteType === "crud") ? amMethods : undefined, modelName: amModelName || undefined, loginField: amIsAuthType ? amLoginField : undefined, passwordField: amIsAuthType && amPasswordField ? amPasswordField : undefined, registerFields: amRouteType === "register" ? amRegisterFields : undefined, selectedFields: amRouteType === "methods" && amSelectedFields.length > 0 ? amSelectedFields : undefined, customFields: amRouteType === "methods" && amCustomFields.length > 0 ? amCustomFields : undefined, }, }); toast(result.message, "success"); setRouteContents(prev => ({ ...prev, [selectedRoute]: { content: result.content } })); setShowAddMethod(false); refresh(); } catch (err) { toast(err.message, "error"); } setAmLoading(false); }} >${amLoading ? html`<span className="spinner" />` : "Add to Route"}</button>`} </div>`; })()} <div className="card" style=${{ padding: 0, overflow: "hidden" }} > <div style=${{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "space-between", }} > <span style=${{ fontSize: "12px", fontWeight: 600, color: "var(--text-secondary)", }} >Source Code</span > <span className="field-count" >${selContent ? selContent.split("\\n").length + " lines" : ""}</span > </div> ${loadingRoute === selectedRoute && html`<div style=${{ padding: "30px", textAlign: "center" }}> <span className="spinner" /> </div>`} ${selContent && html`<div className="route-code-wrap"> <div className="code-block">${selContent}</div> </div>`} </div> </div>`} </div> </div> </div>`; } function CreateRoutePanel({ onCreated }) { const { data, toast } = useApp(); const [name, setName] = useState(""); const [routeType, setRouteType] = useState(null); const [methods, setMethods] = useState([]); const pathPrefix = "/api/"; const [modelName, setModelName] = useState(""); const [loginField, setLoginField] = useState(""); const [passwordField, setPasswordField] = useState(""); const [registerFields, setRegisterFields] = useState([]); const [selectedFields, setSelectedFields] = useState([]); const [customFields, setCustomFields] = useState([]); const [authRequired, setAuthRequired] = useState(false); const [newFieldName, setNewFieldName] = useState(""); const [newFieldType, setNewFieldType] = useState("String"); const [newFieldRequired, setNewFieldRequired] = useState(true); const [loading, setLoading] = useState(false); const nameRef = useRef(null); useEffect(() => { nameRef.current?.focus(); }, []); const typeOptions = [ { id: "plain", label: "Plain", desc: "Empty route", color: "#94a3b8", }, { id: "methods", label: "Methods", desc: "GET, POST, PUT...", color: "var(--accent-light)", }, { id: "crud", label: "CRUD", desc: "Model CRUD ops", color: "#3b82f6", }, { id: "login", label: "Login", desc: "JWT login", color: "#22d3ee" }, { id: "register", label: "Register", desc: "Sign up", color: "#34d399", }, ]; function selectType(id) { setRouteType((prev) => (prev === id ? null : id)); if (id !== "methods" && id !== "crud") setMethods([]); } function toggleMethod(m) { setMethods((prev) => prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m], ); } const needsModel = routeType && routeType !== "methods" && routeType !== "plain"; const isAuthType = routeType === "login" || routeType === "register"; // Get fields from selected model const selectedModelObj = data.models?.find((m) => m.name === modelName); const modelFields = selectedModelObj?.fields || []; // Auto-select fields when model changes useEffect(() => { if (modelFields.length > 0 && isAuthType) { if (!loginField) { const emailField = modelFields.find((f) => f.name === "email") || modelFields[0]; setLoginField(emailField?.name || ""); } if (!passwordField) { const pwField = modelFields.find((f) => f.name === "password"); setPasswordField(pwField?.name || ""); } if (routeType === "register" && registerFields.length === 0) { setRegisterFields(modelFields.map((f) => f.name)); } } }, [modelName, routeType]); async function handleCreate() { if (!name.trim() || !routeType) return; if (routeType === "methods" && methods.length === 0) { toast("Select at least one method", "error"); return; } if (routeType === "crud" && methods.length === 0) { toast("Select at least one CRUD method", "error"); return; } if (needsModel && !modelName) { toast("Select a model", "error"); return; } if (isAuthType && !loginField) { toast("Select a login field", "error"); return; } setLoading(true); try { const result = await api("/add-api", { method: "POST", body: { name: name.trim(), routeType, pathPrefix, methods: routeType === "methods" || routeType === "crud" ? methods : undefined, modelName: modelName || undefined, loginField: isAuthType ? loginField : undefined, passwordField: isAuthType && passwordField ? passwordField : undefined, registerFields: routeType === "register" ? registerFields : undefined, selectedFields: (routeType === "methods" || routeType === "crud") && selectedFields.length > 0 ? selectedFields : undefined, customFields: (routeType === "methods" || routeType === "crud") && customFields.length > 0 ? customFields : undefined, authRequired: !isAuthType ? authRequired : undefined, }, }); toast(result.message, "success"); onCreated(); } catch (err) { toast(err.message, "error"); } setLoading(false); } const methodColors = { get: "#22c55e", post: "#3b82f6", put: "#f59e0b", patch: "#a855f7", delete: "#ef4444", }; const canCreate = name.trim() && routeType && (routeType !== "methods" || methods.length > 0) && (routeType !== "crud" || methods.length > 0) && (!needsModel || modelName) && (!isAuthType || loginField); return html`<div> <div className="card mb-4"> <div className="flex items-center justify-between" style=${{ marginBottom: "16px" }} > <div style=${{ fontSize: "15px", fontWeight: 600 }}> Create New Route </div> <button className="btn btn-primary btn-sm" onClick=${handleCreate} disabled=${loading || !canCreate} > ${loading ? html`<span className="spinner" />` : html`<span className="sidebar-icon">${Icons.plus}</span>`} ${loading ? "Creating..." : "Create Route"} </button> </div> <div className="form-group"> <label className="form-label">Route Name</label> <input ref=${nameRef} className="form-input" value=${name} onInput=${(e) => setName(e.target.value)} onKeyDown=${(e) => e.key === "Enter" && canCreate && handleCreate()} placeholder="e.g. users, login, products" style=${{ fontSize: "14px", padding: "11px 14px" }} /> </div> <div className="form-group"> <label className="form-label">Type</label> <div style=${{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "6px", }} > ${typeOptions.map((t) => { const active = routeType === t.id; return html`<button key=${t.id} style=${{ padding: "8px 4px", borderRadius: "var(--radius)", cursor: "pointer", textAlign: "center", border: active ? "1.5px solid " + t.color : "1.5px solid var(--border)", background: active ? "rgba(255,255,255,0.04)" : "var(--bg-input)", opacity: active ? 1 : 0.5, transition: "all 0.15s", }} onClick=${() => selectType(t.id)} > <div style=${{ fontSize: "11px", fontWeight: 700, color: active ? t.color : "var(--text-secondary)", }} > ${t.label} </div> <div style=${{ fontSize: "9px", color: "var(--text-muted)", marginTop: "1px", }} > ${t.desc} </div> </button>`; })} </div> </div> ${routeType === "methods" && html`<div className="form-group"> <label className="form-label">HTTP Methods</label> <div className="flex gap-2"> ${["get", "post", "put", "patch", "delete"].map( (m) => html`<button key=${m} className=${`route-method ${m}`} style=${{ cursor: "pointer", border: methods.includes(m) ? "2px solid " + methodColors[m] : "2px solid transparent", padding: "6px 12px", fontSize: "11px", opacity: methods.includes(m) ? 1 : 0.35, borderRadius: "6px", transition: "all 0.15s", }} onClick=${() => toggleMethod(m)} > ${m.toUpperCase()} </button>`, )} </div> </div>`} ${routeType === "crud" && html`<div className="form-group"> <label className="form-label" >CRUD Methods <span style=${{ color: "var(--text-muted)", fontWeight: 400 }} >(select which operations to include)</span ></label > <div className="flex gap-2"> ${["get", "post", "put", "patch", "delete"].map((m) => { const labels = { get: "READ", post: "CREATE", put: "UPDATE", patch: "PATCH", delete: "DELETE", }; return html`<button key=${m} className=${`route-method ${m}`} style=${{ cursor: "pointer", border: methods.includes(m) ? "2px solid " + methodColors[m] : "2px solid transparent", padding: "6px 12px", fontSize: "11px", opacity: methods.includes(m) ? 1 : 0.35, borderRadius: "6px", transition: "all 0.15s", }} onClick=${() => toggleMethod(m)} > ${labels[m]} </button>`; })} </div> </div>`} ${isAuthType && html`<div className="form-group"> <label className="form-label">Model</label> <select className="form-select" value=${modelName} onChange=${(e) => { setModelName(e.target.value); setLoginField(""); setPasswordField(""); setRegisterFields([]); setSelectedFields([]); }} > <option value="">Select a model...</option> ${(data.models || []).map( (m) => html`<option key=${m.name} value=${m.name}> ${m.name} </option>`, )} </select> ${!data.models?.length && html`<div style=${{ fontSize: "11px", color: "var(--warning)", marginTop: "4px", }} > No models found. Create a schema first. </div>`} </div>`} ${(routeType === "methods" || routeType === "crud") && html`<div className="form-group"> <label className="form-label" >Model Fields <span style=${{ color: "var(--text-muted)", fontWeight: 400 }} >(select fields to include in request)</span ></label > <select className="form-select" style=${{ marginBottom: "8px" }} value=${modelName} onChange=${(e) => { setModelName(e.target.value); setSelectedFields([]); }} > <option value="">Select a model...</option> ${(data.models || []).map( (m) => html`<option key=${m.name} value=${m.name}> ${m.name} </option>`, )} </select> ${modelName && modelFields.length > 0 && html`<div> <div className="flex gap-2" style=${{ flexWrap: "wrap" }}> ${modelFields.map((f) => { const active = selectedFields.includes(f.name); return html`<button key=${f.name} style=${{ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", border: active ? "1.5px solid var(--accent)" : "1.5px solid var(--border)", background: active ? "var(--accent-glow)" : "var(--bg-input)", color: active ? "var(--accent-light)" : "var(--text-secondary)", fontWeight: active ? 600 : 400, transition: "all 0.15s", }} onClick=${() => setSelectedFields((prev) => active ? prev.filter((x) => x !== f.name) : [...prev, f.name], )} > ${f.name} <span style=${{ fontSize: "10px", color: "var(--text-muted)", marginLeft: "4px", }} >${f.type}</span > </button>`; })} </div> <div style=${{ display: "flex", gap: "8px", marginTop: "8px" }}> <button style=${{ padding: "3px 10px", fontSize: "10px", borderRadius: "4px", border: "1px solid var(--border)", background: "transparent", color: "var(--accent)", cursor: "pointer", }} onClick=${() => setSelectedFields(modelFields.map((f) => f.name))} > Select All </button> <button style=${{ padding: "3px 10px", fontSize: "10px", borderRadius: "4px", border: "1px solid var(--border)", background: "transparent", color: "var(--text-muted)", cursor: "pointer", }} onClick=${() => setSelectedFields([])} > Clear </button> </div> </div>`} ${!data.models?.length && html`<div style=${{ fontSize: "11px", color: "var(--text-muted)", marginTop: "4px", }} > No models found. You can still add custom fields below. </div>`} </div>`} ${(routeType === "methods" || routeType === "crud") && html`<div className="form-group"> <label className="form-label" >Custom Fields <span style=${{ color: "var(--text-muted)", fontWeight: 400 }} >(add extra fields received from client)</span ></label > ${customFields.length > 0 && html`<div style=${{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "8px", }} > ${customFields.map( (f, i) => html`<span key=${i} style=${{ padding: "4px 10px", borderRadius: "6px", fontSize: "12px", background: "var(--bg-input)", border: "1.5px solid var(--border)", color: "var(--text-primary)", display: "flex", alignItems: "center", gap: "6px", }} > ${f.name} <span style=${{ fontSize: "10px", color: f.type === "File" ? "var(--warning)" : "var(--text-muted)", }} >${f.type}</span > <span title="Click to toggle required / optional" style=${{ cursor: "pointer", fontSize: "9px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.3px", padding: "1px 5px", borderRadius: "4px", color: f.required === false ? "var(--text-muted)" : "var(--success)", background: f.required === false ? "var(--bg-input)" : "var(--success-glow)", }} onClick=${() => setCustomFields((prev) => prev.map((x, idx) => idx === i ? { ...x, required: x.required === false } : x, ), )} >${f.required === false ? "optional" : "required"}</span > <span style=${{ cursor: "pointer", color: "var(--text-muted)", fontSize: "14px", lineHeight: 1, marginLeft: "2px", }} onClick=${() => setCustomFields((prev) => prev.filter((_, idx) => idx !== i), )} >×</span > </span>`, )} </div>`} <div className="flex gap-2" style=${{ alignItems: "center" }}> <input className="form-input" value=${newFieldName} onInput=${(e) => setNewFieldName(e.target.value)} placeholder="Field name" style=${{ fontSize: "13px", padding: "8px 12px", flex: 1, }} onKeyDown=${(e) => { if (e.key === "Enter" && newFieldName.trim()) { setCustomFields((prev) => [ ...prev, { name: newFieldName.trim(), type: newFieldType, required: newFieldRequired }, ]); setNewFieldName(""); } }} /> <select className="form-select" value=${newFieldType} onChange=${(e) => setNewFieldType(e.target.value)} style=${{ width: "auto", minWidth: "100px" }} > <option value="String">String</option> <option value="Number">Number</option> <option value="Boolean">Boolean</option> <option value="Date">Date</option> <option value="Array">Array</option> <option value="Object">Object</option> <option value="File">File</option> </select> <label title="New field is required in the request body (validated by Zod)" style=${{ display: "flex", alignItems: "center", gap: "5px", fontSize: "12px", color: "var(--text-secondary)", cursor: "pointer", whiteSpace: "nowrap", }} > <input type="checkbox" checked=${newFieldRequired} onChange=${(e) => setNewFieldRequired(e.target.checked)} /> required </label> <button className="btn btn-sm" style=${{ padding: "8px 14px", fontSize: "12px", background: "var(--bg-input)", border: "1.5px solid var(--border)", color: "var(--text-primary)", cursor: newFieldName.trim() ? "pointer" : "not-allowed", opacity: newFieldName.trim() ? 1 : 0.4, borderRadius: "6px", }} disabled=${!newFieldName.trim()} onClick=${() => { if (newFieldName.trim()) { setCustomFields((prev) => [ ...prev, { name: newFieldName.trim(), type: newFieldType, required: newFieldRequired }, ]); setNewFieldName(""); } }} > + Add </button> </div> </div>`} ${routeType && !isAuthType && html`<div className="form-group" style=${{ marginTop: "4px" }}> <lab