@nexa-stack/framework 1.0.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.
Files changed (81) hide show
  1. package/.env.example +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/bin/nexa.mjs +41 -0
  5. package/bin/nexa.ts +334 -0
  6. package/docs/AI.md +69 -0
  7. package/docs/ARCHITECTURE.md +74 -0
  8. package/docs/EXAMPLES.md +114 -0
  9. package/docs/FRAMEWORK.md +226 -0
  10. package/docs/LANGUAGE.md +39 -0
  11. package/docs/README.md +7 -0
  12. package/docs/READY.md +51 -0
  13. package/docs/REFERENCE.md +255 -0
  14. package/docs/START.md +98 -0
  15. package/docs/advanced.md +97 -0
  16. package/docs/authentication.md +54 -0
  17. package/docs/cli.md +15 -0
  18. package/docs/compare.md +51 -0
  19. package/docs/configuration.md +65 -0
  20. package/docs/database.md +396 -0
  21. package/docs/installation.md +57 -0
  22. package/docs/localization.md +47 -0
  23. package/docs/resources.md +75 -0
  24. package/docs/routing.md +59 -0
  25. package/docs/seeding.md +33 -0
  26. package/docs/services.md +146 -0
  27. package/package.json +77 -0
  28. package/packages/auth/src/auth.test.ts +23 -0
  29. package/packages/auth/src/auth.ts +287 -0
  30. package/packages/auth/src/index.ts +17 -0
  31. package/packages/cache/src/index.ts +203 -0
  32. package/packages/client/src/index.ts +49 -0
  33. package/packages/core/src/app.ts +97 -0
  34. package/packages/core/src/config.ts +55 -0
  35. package/packages/core/src/dev.ts +104 -0
  36. package/packages/core/src/fields.test.ts +81 -0
  37. package/packages/core/src/fields.ts +309 -0
  38. package/packages/core/src/index.ts +60 -0
  39. package/packages/core/src/lang.ts +79 -0
  40. package/packages/core/src/loader.ts +42 -0
  41. package/packages/core/src/migrate.ts +91 -0
  42. package/packages/core/src/policy.ts +36 -0
  43. package/packages/core/src/registry.ts +17 -0
  44. package/packages/core/src/reload.ts +92 -0
  45. package/packages/core/src/resource.test.ts +32 -0
  46. package/packages/core/src/resource.ts +87 -0
  47. package/packages/core/src/routes.ts +22 -0
  48. package/packages/core/src/runtime.ts +11 -0
  49. package/packages/database/src/builder.ts +266 -0
  50. package/packages/database/src/database.ts +252 -0
  51. package/packages/database/src/dialect.ts +186 -0
  52. package/packages/database/src/index.ts +6 -0
  53. package/packages/database/src/mysql.ts +114 -0
  54. package/packages/database/src/postgres.ts +117 -0
  55. package/packages/database/src/query.ts +115 -0
  56. package/packages/database/src/sqlite.ts +216 -0
  57. package/packages/database/src/types.ts +104 -0
  58. package/packages/events/src/index.ts +17 -0
  59. package/packages/export/src/index.ts +36 -0
  60. package/packages/log/src/index.ts +38 -0
  61. package/packages/mail/src/index.ts +130 -0
  62. package/packages/notifications/src/index.ts +84 -0
  63. package/packages/plugins/src/index.ts +39 -0
  64. package/packages/queue/src/index.ts +185 -0
  65. package/packages/queue/src/jobs.ts +9 -0
  66. package/packages/schedule/src/index.ts +64 -0
  67. package/packages/server/src/index.ts +1 -0
  68. package/packages/server/src/middleware.ts +143 -0
  69. package/packages/server/src/query.ts +40 -0
  70. package/packages/server/src/router.ts +813 -0
  71. package/packages/sms/src/index.ts +33 -0
  72. package/packages/storage/src/upload.ts +36 -0
  73. package/packages/testing/src/index.ts +67 -0
  74. package/packages/validation/src/index.ts +1 -0
  75. package/packages/validation/src/validate.test.ts +35 -0
  76. package/packages/validation/src/validate.ts +112 -0
  77. package/public/admin.html +369 -0
  78. package/public/compare.html +66 -0
  79. package/public/dev-bar.js +213 -0
  80. package/public/docs.html +315 -0
  81. package/public/index.html +66 -0
@@ -0,0 +1,33 @@
1
+ import { appendFileSync, mkdirSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { config } from "../../core/src/config.js";
4
+ import { log } from "../../log/src/index.js";
5
+
6
+ export async function sms(to: string, message: string): Promise<void> {
7
+ const driver = config("SMS_DRIVER") || "log";
8
+
9
+ if (driver === "log") {
10
+ const dir = join(process.cwd(), "storage", "logs");
11
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
12
+ appendFileSync(
13
+ join(dir, "sms.log"),
14
+ `[${new Date().toISOString()}] to=${to} msg=${message}\n`
15
+ );
16
+ log.info(`SMS (log) → ${to}`);
17
+ return;
18
+ }
19
+
20
+ // Hook for Twilio / local gateways — set SMS_WEBHOOK to POST
21
+ const webhook = config("SMS_WEBHOOK");
22
+ if (driver === "webhook" && webhook) {
23
+ await fetch(webhook, {
24
+ method: "POST",
25
+ headers: { "Content-Type": "application/json" },
26
+ body: JSON.stringify({ to, message }),
27
+ });
28
+ log.info(`SMS (webhook) → ${to}`);
29
+ return;
30
+ }
31
+
32
+ throw new Error(`Unknown SMS_DRIVER: ${driver}`);
33
+ }
@@ -0,0 +1,36 @@
1
+ import { mkdirSync, existsSync } from "fs";
2
+ import { writeFile } from "fs/promises";
3
+ import { join, extname } from "path";
4
+ import { randomUUID } from "crypto";
5
+ import { config } from "../../core/src/config.js";
6
+
7
+ const UPLOAD_DIR = join(process.cwd(), "storage", "uploads");
8
+
9
+ export function ensureStorage(): void {
10
+ if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
11
+ }
12
+
13
+ export async function saveUpload(file: File): Promise<string> {
14
+ ensureStorage();
15
+ const maxBytes = Number(config("UPLOAD_MAX_BYTES") || 5_000_000);
16
+ if (file.size > maxBytes) {
17
+ throw new Error(`File too large (max ${maxBytes} bytes)`);
18
+ }
19
+ const ext = extname(file.name) || ".bin";
20
+ const name = `${randomUUID()}${ext}`;
21
+ const path = join(UPLOAD_DIR, name);
22
+ const buf = Buffer.from(await file.arrayBuffer());
23
+ await writeFile(path, buf);
24
+ return `/storage/uploads/${name}`;
25
+ }
26
+
27
+ export function storagePath(urlPath: string): string | null {
28
+ if (!urlPath.startsWith("/storage/uploads/")) return null;
29
+ const name = urlPath.replace("/storage/uploads/", "");
30
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) return null;
31
+ return join(UPLOAD_DIR, name);
32
+ }
33
+
34
+ export function isImageExt(ext: string): boolean {
35
+ return [".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext.toLowerCase());
36
+ }
@@ -0,0 +1,67 @@
1
+ import { createDatabase, type Database } from "../../database/src/database.js";
2
+ import { Router } from "../../server/src/router.js";
3
+ import { setupAuth, login, signToken, type AuthUser } from "../../auth/src/auth.js";
4
+ import { setupQueue } from "../../queue/src/index.js";
5
+ import { setupNotifications } from "../../notifications/src/index.js";
6
+ import { getResources } from "../../core/src/registry.js";
7
+ import { getCustomRoutes } from "../../core/src/routes.js";
8
+
9
+ export interface TestApp {
10
+ db: Database;
11
+ router: Router;
12
+ request: (method: string, path: string, opts?: RequestInit & { token?: string }) => Promise<Response>;
13
+ actingAs: (user: AuthUser) => { request: TestApp["request"] };
14
+ loginAs: (email: string, password: string) => Promise<string>;
15
+ close: () => void;
16
+ }
17
+
18
+ /** Build an isolated in-memory app for tests (call after defining resources). */
19
+ export async function createTestApp(): Promise<TestApp> {
20
+ const db = createDatabase(":memory:");
21
+ await db.connect();
22
+
23
+ for (const def of getResources()) {
24
+ await db.createTable(def);
25
+ }
26
+ await setupQueue(db);
27
+ await setupNotifications(db);
28
+ await setupAuth({ secret: "test-secret" }, db);
29
+
30
+ const router = new Router(db);
31
+ for (const def of getResources()) router.registerResource(def);
32
+ router.registerAuthRoutes();
33
+ router.registerNotificationRoutes();
34
+ router.registerCustomRoutes([...getCustomRoutes()]);
35
+
36
+ const request: TestApp["request"] = async (method, path, opts = {}) => {
37
+ const headers = new Headers(opts.headers);
38
+ if (opts.token) headers.set("Authorization", `Bearer ${opts.token}`);
39
+ if (opts.body && !headers.has("Content-Type") && typeof opts.body === "string") {
40
+ headers.set("Content-Type", "application/json");
41
+ }
42
+ const { token: _t, ...rest } = opts;
43
+ return router.handle(
44
+ new Request(`http://localhost${path}`, { method, ...rest, headers })
45
+ );
46
+ };
47
+
48
+ return {
49
+ db,
50
+ router,
51
+ request,
52
+ actingAs(user) {
53
+ const token = signToken(user);
54
+ return {
55
+ request: (method, path, opts = {}) => request(method, path, { ...opts, token }),
56
+ };
57
+ },
58
+ async loginAs(email, password) {
59
+ const result = await login(email, password);
60
+ if (!result) throw new Error("login failed");
61
+ return result.token;
62
+ },
63
+ close() {
64
+ db.close();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1 @@
1
+ export { validate, type ValidationError } from "./validate.js";
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { validate } from "./validate.js";
3
+ import { resolveFields, string, number, email, belongsTo } from "../../core/src/fields.js";
4
+
5
+ describe("validate", () => {
6
+ test("required field missing", () => {
7
+ const fields = resolveFields({ name: string().required() });
8
+ const result = validate(fields, {});
9
+ expect(result.valid).toBe(false);
10
+ if (!result.valid) expect(result.errors[0].field).toBe("name");
11
+ });
12
+
13
+ test("valid string and number", () => {
14
+ const fields = resolveFields({ name: string().required(), price: number().required() });
15
+ const result = validate(fields, { name: "Book", price: 10 });
16
+ expect(result.valid).toBe(true);
17
+ if (result.valid) {
18
+ expect(result.data.name).toBe("Book");
19
+ expect(result.data.price).toBe(10);
20
+ }
21
+ });
22
+
23
+ test("invalid email", () => {
24
+ const fields = resolveFields({ mail: email().required() });
25
+ const result = validate(fields, { mail: "bad" });
26
+ expect(result.valid).toBe(false);
27
+ });
28
+
29
+ test("relation accepts id", () => {
30
+ const fields = resolveFields({ category: belongsTo("categories").required() });
31
+ const result = validate(fields, { category: 1 });
32
+ expect(result.valid).toBe(true);
33
+ if (result.valid) expect(result.data.category_id).toBe(1);
34
+ });
35
+ });
@@ -0,0 +1,112 @@
1
+ import type { FieldDef } from "../../core/src/fields.js";
2
+ import { fieldColumn, isStoredField } from "../../core/src/fields.js";
3
+
4
+ export interface ValidationError {
5
+ field: string;
6
+ message: string;
7
+ }
8
+
9
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10
+
11
+ export function validate(
12
+ fields: Record<string, FieldDef>,
13
+ data: Record<string, unknown>,
14
+ partial = false
15
+ ):
16
+ | { valid: true; data: Record<string, unknown> }
17
+ | { valid: false; errors: ValidationError[] } {
18
+ const errors: ValidationError[] = [];
19
+ const parsed: Record<string, unknown> = {};
20
+
21
+ for (const [name, field] of Object.entries(fields)) {
22
+ if (!isStoredField(field)) continue;
23
+
24
+ const col = fieldColumn(name, field);
25
+ if (!col) continue;
26
+
27
+ const value = data[name] ?? data[col];
28
+
29
+ if (value === undefined || value === null || value === "") {
30
+ if (field.required && !partial) {
31
+ errors.push({ field: name, message: `${name} is required` });
32
+ }
33
+ continue;
34
+ }
35
+
36
+ switch (field.type) {
37
+ case "number":
38
+ case "money": {
39
+ const num = Number(value);
40
+ if (Number.isNaN(num)) {
41
+ errors.push({ field: name, message: `${name} must be a number` });
42
+ } else {
43
+ if (field.min !== undefined && num < field.min) {
44
+ errors.push({ field: name, message: `${name} min is ${field.min}` });
45
+ }
46
+ if (field.max !== undefined && num > field.max) {
47
+ errors.push({ field: name, message: `${name} max is ${field.max}` });
48
+ }
49
+ parsed[col] = num;
50
+ }
51
+ break;
52
+ }
53
+ case "boolean": {
54
+ parsed[col] = value === true || value === "true" || value === 1 || value === "1";
55
+ break;
56
+ }
57
+ case "email": {
58
+ const str = String(value);
59
+ if (!EMAIL_RE.test(str)) {
60
+ errors.push({ field: name, message: `${name} must be a valid email` });
61
+ } else {
62
+ parsed[col] = str;
63
+ }
64
+ break;
65
+ }
66
+ case "select": {
67
+ const str = String(value);
68
+ if (field.options?.length && !field.options.includes(str)) {
69
+ errors.push({
70
+ field: name,
71
+ message: `${name} must be one of: ${field.options.join(", ")}`,
72
+ });
73
+ } else {
74
+ parsed[col] = str;
75
+ }
76
+ break;
77
+ }
78
+ case "date":
79
+ case "datetime": {
80
+ parsed[col] = String(value);
81
+ break;
82
+ }
83
+ case "relation": {
84
+ const num = Number(value);
85
+ if (!Number.isInteger(num) || num <= 0) {
86
+ errors.push({ field: name, message: `${name} must be a valid id` });
87
+ } else {
88
+ parsed[col] = num;
89
+ }
90
+ break;
91
+ }
92
+ case "file":
93
+ case "image": {
94
+ parsed[col] = String(value);
95
+ break;
96
+ }
97
+ default: {
98
+ const str = String(value);
99
+ if (field.min !== undefined && str.length < field.min) {
100
+ errors.push({ field: name, message: `${name} min length is ${field.min}` });
101
+ }
102
+ if (field.max !== undefined && str.length > field.max) {
103
+ errors.push({ field: name, message: `${name} max length is ${field.max}` });
104
+ }
105
+ parsed[col] = str;
106
+ }
107
+ }
108
+ }
109
+
110
+ if (errors.length > 0) return { valid: false, errors };
111
+ return { valid: true, data: parsed };
112
+ }
@@ -0,0 +1,369 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" dir="ltr">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Nexa</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: system-ui, sans-serif; background: #f1f5f9; min-height: 100vh; color: #1e293b; }
10
+
11
+ #login { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0f172a; }
12
+ .login-card { background: #fff; padding: 36px; border-radius: 16px; width: 380px; }
13
+ .login-card h1 { margin-bottom: 8px; }
14
+ .login-card p { color: #64748b; font-size: .85rem; margin-bottom: 24px; }
15
+
16
+ input, select {
17
+ width: 100%; padding: 11px 12px; margin-bottom: 12px;
18
+ border: 1px solid #cbd5e1; border-radius: 8px; font-size: .95rem;
19
+ }
20
+ button {
21
+ padding: 10px 18px; background: #2563eb; color: #fff;
22
+ border: none; border-radius: 8px; cursor: pointer; font-size: .9rem;
23
+ }
24
+ button:hover { background: #1d4ed8; }
25
+ button.danger { background: #dc2626; }
26
+ button.ghost { background: #e2e8f0; color: #475569; }
27
+
28
+ #app { display: none; grid-template-columns: 220px 1fr; min-height: 100vh; }
29
+ nav { background: #0f172a; color: #fff; padding: 20px 0; }
30
+ nav .logo { padding: 0 20px 24px; font-size: 1.2rem; font-weight: bold; color: #38bdf8; }
31
+ nav .logo a { color: inherit; text-decoration: none; }
32
+ nav .docs-link {
33
+ display: block; padding: 0 20px 16px; font-size: .8rem; color: #64748b; text-decoration: none;
34
+ }
35
+ nav .docs-link:hover { color: #94a3b8; }
36
+ nav button {
37
+ display: block; width: 100%; text-align: right; padding: 11px 20px;
38
+ background: none; color: #94a3b8; border: none; cursor: pointer; font-size: .9rem;
39
+ }
40
+ nav button.active, nav button:hover { background: #1e293b; color: #fff; }
41
+
42
+ main { padding: 28px; overflow-y: auto; }
43
+ .stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; margin-bottom: 28px; }
44
+ .stat { background: #fff; padding: 18px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
45
+ .stat .num { font-size: 1.8rem; font-weight: bold; color: #2563eb; }
46
+ .stat .lbl { font-size: .8rem; color: #64748b; margin-top: 4px; }
47
+
48
+ table { width: 100%; background: #fff; border-radius: 10px; border-collapse: collapse; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
49
+ th, td { padding: 11px 14px; text-align: right; border-bottom: 1px solid #f1f5f9; font-size: .88rem; }
50
+ th { background: #f8fafc; color: #64748b; }
51
+
52
+ .form-box { background: #fff; padding: 20px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
53
+ .form-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
54
+ label { display: block; font-size: .78rem; color: #64748b; margin-bottom: 4px; }
55
+ .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
56
+ .header h1 { font-size: 1.4rem; }
57
+ .lang-switch { display: flex; gap: 6px; padding: 0 20px 16px; }
58
+ .lang-switch button { flex: 1; padding: 6px; font-size: .8rem; background: #1e293b; color: #94a3b8; }
59
+ .lang-switch button.active { background: #2563eb; color: #fff; }
60
+ .hidden { display: none !important; }
61
+ .error-msg { background: #fee2e2; color: #dc2626; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
62
+ </style>
63
+ </head>
64
+ <body>
65
+
66
+ <div id="login">
67
+ <div class="login-card">
68
+ <h1>Nexa</h1>
69
+ <p id="loginSub">Resource admin</p>
70
+ <p id="loginError" class="error-msg hidden"></p>
71
+ <input id="email" value="admin@nexa.dev" />
72
+ <input id="pass" type="password" value="123456" />
73
+ <button id="loginBtn" onclick="doLogin()" style="width:100%">Login</button>
74
+ <div class="lang-switch" style="padding:16px 0 0;margin:0">
75
+ <button id="lang-ar" onclick="setLang('ar')">عربي</button>
76
+ <button id="lang-en" onclick="setLang('en')">EN</button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <div id="app">
82
+ <nav>
83
+ <div class="logo">Nexa</div>
84
+ <a class="docs-link" href="/docs" target="_blank">Docs ↗</a>
85
+ <div class="lang-switch">
86
+ <button id="nav-lang-ar" onclick="setLang('ar')">عربي</button>
87
+ <button id="nav-lang-en" onclick="setLang('en')">EN</button>
88
+ </div>
89
+ <button onclick="openDash()" id="tab-dashboard">Dashboard</button>
90
+ <div id="menu"></div>
91
+ <button class="ghost" id="logoutBtn" style="margin:20px;color:#64748b;background:none" onclick="logout()">Logout</button>
92
+ </nav>
93
+ <main>
94
+ <div id="dashboard">
95
+ <div class="header"><h1 id="dashTitle">Dashboard</h1></div>
96
+ <div class="stats" id="stats"></div>
97
+ </div>
98
+ <div id="page" class="hidden">
99
+ <div class="header">
100
+ <h1 id="title">—</h1>
101
+ <input id="searchBox" placeholder="Search…" oninput="onSearch()" style="width:220px;margin:0" />
102
+ </div>
103
+ <div id="pageError" class="error-msg hidden"></div>
104
+ <div class="form-box" id="formBox"></div>
105
+ <table><thead id="thead"></thead><tbody id="tbody"></tbody></table>
106
+ </div>
107
+ </main>
108
+ </div>
109
+
110
+ <script>
111
+ const API = location.origin;
112
+ let token = localStorage.getItem("token");
113
+ let schemas = [];
114
+ let current = null;
115
+ let lang = localStorage.getItem("lang") || "en";
116
+ let uiTexts = {};
117
+ let relCache = {};
118
+
119
+ setLang(lang, false);
120
+ if (token) boot();
121
+
122
+ async function setLang(l, reload = true) {
123
+ lang = l;
124
+ localStorage.setItem("lang", l);
125
+ document.documentElement.lang = l;
126
+ document.documentElement.dir = l === "ar" ? "rtl" : "ltr";
127
+ document.querySelectorAll(".lang-switch button").forEach(b => b.classList.remove("active"));
128
+ document.getElementById(`lang-${l}`)?.classList.add("active");
129
+ document.getElementById(`nav-lang-${l}`)?.classList.add("active");
130
+ const pre = await fetch(`${API}/api/schema?lang=${l}`, {
131
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
132
+ });
133
+ const preData = await pre.json();
134
+ uiTexts = preData.ui || {};
135
+ document.getElementById("loginSub").textContent = uiTexts.title || "";
136
+ document.getElementById("loginBtn").textContent = uiTexts.login || "Login";
137
+ if (reload && token) boot();
138
+ }
139
+
140
+ function applyUi() {
141
+ document.getElementById("tab-dashboard").textContent = uiTexts.dashboard || "Dashboard";
142
+ document.getElementById("dashTitle").textContent = uiTexts.dashboard || "Dashboard";
143
+ document.getElementById("logoutBtn").textContent = uiTexts.logout || "Logout";
144
+ }
145
+
146
+ function headers(authRequired = false) {
147
+ const h = { "Content-Type": "application/json" };
148
+ if (token) h.Authorization = `Bearer ${token}`;
149
+ return h;
150
+ }
151
+
152
+ function apiHeaders() {
153
+ return token ? { Authorization: `Bearer ${token}` } : {};
154
+ }
155
+
156
+ function showError(msg) {
157
+ const el = document.getElementById("pageError");
158
+ el.textContent = msg;
159
+ el.classList.remove("hidden");
160
+ }
161
+
162
+ function hideError() {
163
+ document.getElementById("pageError").classList.add("hidden");
164
+ }
165
+
166
+ async function doLogin() {
167
+ const res = await fetch(`${API}/api/auth/login`, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({
171
+ email: document.getElementById("email").value,
172
+ password: document.getElementById("pass").value,
173
+ }),
174
+ });
175
+ const data = await res.json();
176
+ if (!res.ok) {
177
+ const err = document.getElementById("loginError");
178
+ err.textContent = data.error || "Login failed";
179
+ err.classList.remove("hidden");
180
+ return;
181
+ }
182
+ document.getElementById("loginError").classList.add("hidden");
183
+ token = data.data.token;
184
+ localStorage.setItem("token", token);
185
+ boot();
186
+ }
187
+
188
+ function logout() { localStorage.removeItem("token"); location.reload(); }
189
+
190
+ async function boot() {
191
+ const res = await fetch(`${API}/api/schema?lang=${lang}`, { headers: apiHeaders() });
192
+ const data = await res.json();
193
+ if (!res.ok) {
194
+ logout();
195
+ return;
196
+ }
197
+ schemas = data.data || [];
198
+ uiTexts = data.ui || {};
199
+ applyUi();
200
+
201
+ document.getElementById("login").style.display = "none";
202
+ document.getElementById("app").style.display = "grid";
203
+
204
+ document.getElementById("menu").innerHTML = schemas.map(s =>
205
+ `<button onclick="openPage('${s.name}')" id="tab-${s.name}">${s.label}</button>`
206
+ ).join("");
207
+
208
+ openDash();
209
+ }
210
+
211
+ async function openDash() {
212
+ current = null;
213
+ hideError();
214
+ document.querySelectorAll("#menu button, #tab-dashboard").forEach(b => b.classList.remove("active"));
215
+ document.getElementById("tab-dashboard").classList.add("active");
216
+ document.getElementById("dashboard").classList.remove("hidden");
217
+ document.getElementById("page").classList.add("hidden");
218
+
219
+ try {
220
+ const res = await fetch(`${API}/api/dashboard?lang=${lang}`, { headers: apiHeaders() });
221
+ const json = await res.json();
222
+ if (!res.ok) throw new Error(json.error || "Dashboard error");
223
+ document.getElementById("stats").innerHTML = (json.data.stats || []).map(s =>
224
+ `<div class="stat" onclick="openPage('${s.name}')" style="cursor:pointer">
225
+ <div class="num">${s.count}</div><div class="lbl">${s.label}</div></div>`
226
+ ).join("") || `<p style="color:#64748b">${uiTexts.empty || "Empty"}</p>`;
227
+ } catch (e) {
228
+ document.getElementById("stats").innerHTML = `<p class="error-msg">${e.message}</p>`;
229
+ }
230
+ }
231
+
232
+ async function openPage(name) {
233
+ current = schemas.find(s => s.name === name);
234
+ if (!current) return alert("Not found: " + name);
235
+ hideError();
236
+ document.querySelectorAll("#menu button, #tab-dashboard").forEach(b => b.classList.remove("active"));
237
+ document.getElementById(`tab-${name}`)?.classList.add("active");
238
+ document.getElementById("dashboard").classList.add("hidden");
239
+ document.getElementById("page").classList.remove("hidden");
240
+ document.getElementById("title").textContent = current.label;
241
+ await renderForm();
242
+ await load();
243
+ }
244
+
245
+ async function getRel(name) {
246
+ if (relCache[name]) return relCache[name];
247
+ const s = schemas.find(x => x.name === name);
248
+ const res = await fetch(`${API}/api/${name}`, { headers: apiHeaders() });
249
+ const data = await res.json();
250
+ if (!res.ok) throw new Error(data.error || `Failed to load ${name}`);
251
+ relCache[name] = data.data;
252
+ return data.data;
253
+ }
254
+
255
+ async function renderForm() {
256
+ const box = document.getElementById("formBox");
257
+ let html = `<h3 style="margin-bottom:14px">${uiTexts.add || "Add"} ${current.label}</h3><div class="form-row">`;
258
+
259
+ try {
260
+ for (const f of current.fields) {
261
+ const label = f.label || f.name;
262
+ if (f.type === "relation") {
263
+ const items = await getRel(f.relation);
264
+ html += `<div><label>${label}</label><select id="f-${f.name}" ${f.required ? "required" : ""}>
265
+ <option value="">—</option>
266
+ ${items.map(i => `<option value="${i.id}">${i.name || i.title || "#"+i.id}</option>`).join("")}
267
+ </select></div>`;
268
+ } else if (f.type === "select") {
269
+ const opts = f.options || [];
270
+ html += `<div><label>${label}</label><select id="f-${f.name}" ${f.required ? "required" : ""}>
271
+ <option value="">—</option>
272
+ ${opts.map(o => `<option value="${o}">${o}</option>`).join("")}
273
+ </select></div>`;
274
+ } else if (f.type === "boolean") {
275
+ html += `<div><label>${label}</label>
276
+ <select id="f-${f.name}"><option value="false">${lang === "ar" ? "لا" : "No"}</option><option value="true">${lang === "ar" ? "نعم" : "Yes"}</option></select></div>`;
277
+ } else if (f.type === "text") {
278
+ html += `<div style="grid-column:1/-1"><label>${label}</label>
279
+ <textarea id="f-${f.name}" rows="3" style="width:100%;padding:11px 12px;border:1px solid #cbd5e1;border-radius:8px;font:inherit" ${f.required ? "required" : ""}></textarea></div>`;
280
+ } else {
281
+ const type =
282
+ f.type === "number" || f.type === "money" ? "number" :
283
+ f.type === "date" ? "date" :
284
+ f.type === "datetime" ? "datetime-local" :
285
+ f.type === "email" ? "email" : "text";
286
+ html += `<div><label>${label}</label>
287
+ <input id="f-${f.name}" type="${type}" ${f.required ? "required" : ""} /></div>`;
288
+ }
289
+ }
290
+ html += `</div><button type="button" onclick="add()" style="margin-top:14px">${uiTexts.add || "Add"}</button>`;
291
+ box.innerHTML = html;
292
+ } catch (e) {
293
+ box.innerHTML = `<p class="error-msg">${e.message}</p>`;
294
+ }
295
+ }
296
+
297
+ function val(row, f) {
298
+ const v = row[f.name];
299
+ if (f.type === "boolean") {
300
+ const yes = lang === "ar" ? "نعم" : "Yes";
301
+ const no = lang === "ar" ? "لا" : "No";
302
+ return v === true || v === 1 || v === "1" ? yes : no;
303
+ }
304
+ if (v && typeof v === "object") return v.name || v.title || `#${v.id}`;
305
+ return v ?? row[f.name + "_id"] ?? "—";
306
+ }
307
+
308
+ let searchTimer = null;
309
+
310
+ function onSearch() {
311
+ clearTimeout(searchTimer);
312
+ searchTimer = setTimeout(() => load(), 300);
313
+ }
314
+
315
+ async function load() {
316
+ try {
317
+ const q = document.getElementById("searchBox")?.value?.trim();
318
+ const url = new URL(`${API}/api/${current.name}`);
319
+ if (q) url.searchParams.set("search", q);
320
+ const res = await fetch(url, { headers: apiHeaders() });
321
+ const data = await res.json();
322
+ if (!res.ok) throw new Error(data.error || "Load failed");
323
+
324
+ const rows = data.data ?? [];
325
+ document.getElementById("thead").innerHTML =
326
+ `<tr>${current.fields.map(f => `<th>${f.label || f.name}</th>`).join("")}<th></th></tr>`;
327
+ document.getElementById("tbody").innerHTML = rows.length
328
+ ? rows.map(row => `<tr>
329
+ ${current.fields.map(f => `<td>${val(row, f)}</td>`).join("")}
330
+ <td><button class="danger" onclick="del(${row.id})">${uiTexts.delete || "Delete"}</button></td></tr>`).join("")
331
+ : `<tr><td colspan="${current.fields.length + 1}">${uiTexts.empty || "Empty"}</td></tr>`;
332
+ } catch (e) {
333
+ showError(e.message);
334
+ document.getElementById("tbody").innerHTML = "";
335
+ }
336
+ }
337
+
338
+ async function add() {
339
+ const body = {};
340
+ for (const f of current.fields) {
341
+ const el = document.getElementById(`f-${f.name}`);
342
+ if (!el) continue;
343
+ const v = el.value;
344
+ if (f.type === "number" || f.type === "money") body[f.name] = Number(v);
345
+ else if (f.type === "boolean") body[f.name] = v === "true";
346
+ else if (f.type === "relation") body[f.name] = v ? Number(v) : undefined;
347
+ else body[f.name] = v;
348
+ }
349
+ const res = await fetch(`${API}/api/${current.name}`, {
350
+ method: "POST",
351
+ headers: headers(),
352
+ body: JSON.stringify(body),
353
+ });
354
+ const d = await res.json();
355
+ if (!res.ok) return showError(d.error || JSON.stringify(d.errors));
356
+ hideError();
357
+ relCache = {};
358
+ await renderForm();
359
+ await load();
360
+ }
361
+
362
+ async function del(id) {
363
+ if (!confirm(uiTexts.confirm_delete || "Delete?")) return;
364
+ await fetch(`${API}/api/${current.name}/${id}`, { method: "DELETE", headers: apiHeaders() });
365
+ await load();
366
+ }
367
+ </script>
368
+ </body>
369
+ </html>