chadstart 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.
- package/.dockerignore +10 -0
- package/.env.example +46 -0
- package/.github/workflows/browser-test.yml +34 -0
- package/.github/workflows/docker-publish.yml +54 -0
- package/.github/workflows/docs.yml +31 -0
- package/.github/workflows/npm-chadstart.yml +27 -0
- package/.github/workflows/npm-sdk.yml +38 -0
- package/.github/workflows/test.yml +85 -0
- package/.weblate +9 -0
- package/Dockerfile +23 -0
- package/README.md +348 -0
- package/admin/index.html +2802 -0
- package/admin/login.html +207 -0
- package/chadstart.example.yml +416 -0
- package/chadstart.schema.json +367 -0
- package/chadstart.yaml +53 -0
- package/cli/cli.js +295 -0
- package/core/api-generator.js +606 -0
- package/core/auth.js +298 -0
- package/core/db.js +384 -0
- package/core/entity-engine.js +166 -0
- package/core/error-reporter.js +132 -0
- package/core/file-storage.js +97 -0
- package/core/functions-engine.js +353 -0
- package/core/openapi.js +171 -0
- package/core/plugin-loader.js +92 -0
- package/core/realtime.js +93 -0
- package/core/schema-validator.js +50 -0
- package/core/seeder.js +231 -0
- package/core/telemetry.js +119 -0
- package/core/upload.js +372 -0
- package/core/workers/php_worker.php +19 -0
- package/core/workers/python_worker.py +33 -0
- package/core/workers/ruby_worker.rb +21 -0
- package/core/yaml-loader.js +64 -0
- package/demo/chadstart.yaml +178 -0
- package/demo/docker-compose.yml +31 -0
- package/demo/functions/greet.go +39 -0
- package/demo/functions/hello.cpp +18 -0
- package/demo/functions/hello.py +13 -0
- package/demo/functions/hello.rb +10 -0
- package/demo/functions/onTodoCreated.js +13 -0
- package/demo/functions/ping.sh +13 -0
- package/demo/functions/stats.js +22 -0
- package/demo/public/index.html +522 -0
- package/docker-compose.yml +17 -0
- package/docs/access-policies.md +155 -0
- package/docs/admin-ui.md +29 -0
- package/docs/angular.md +69 -0
- package/docs/astro.md +71 -0
- package/docs/auth.md +160 -0
- package/docs/cli.md +56 -0
- package/docs/config.md +127 -0
- package/docs/crud.md +627 -0
- package/docs/deploy.md +113 -0
- package/docs/docker.md +59 -0
- package/docs/entities.md +385 -0
- package/docs/functions.md +196 -0
- package/docs/getting-started.md +79 -0
- package/docs/groups.md +85 -0
- package/docs/index.md +5 -0
- package/docs/llm-rules.md +81 -0
- package/docs/middlewares.md +78 -0
- package/docs/overrides/home.html +350 -0
- package/docs/plugins.md +59 -0
- package/docs/react.md +75 -0
- package/docs/realtime.md +43 -0
- package/docs/s3-storage.md +40 -0
- package/docs/security.md +23 -0
- package/docs/stylesheets/extra.css +375 -0
- package/docs/svelte.md +71 -0
- package/docs/telemetry.md +97 -0
- package/docs/upload.md +168 -0
- package/docs/validation.md +115 -0
- package/docs/vue.md +86 -0
- package/docs/webhooks.md +87 -0
- package/index.js +11 -0
- package/locales/en/admin.json +169 -0
- package/mkdocs.yml +82 -0
- package/package.json +65 -0
- package/playwright.config.js +24 -0
- package/public/.gitkeep +0 -0
- package/sdk/README.md +284 -0
- package/sdk/package.json +39 -0
- package/sdk/scripts/build.js +58 -0
- package/sdk/src/index.js +368 -0
- package/sdk/test/sdk.test.cjs +340 -0
- package/sdk/types/index.d.ts +217 -0
- package/server/express-server.js +734 -0
- package/test/access-policies.test.js +96 -0
- package/test/ai.test.js +81 -0
- package/test/api-keys.test.js +361 -0
- package/test/auth.test.js +122 -0
- package/test/browser/admin-ui.spec.js +127 -0
- package/test/browser/global-setup.js +71 -0
- package/test/browser/global-teardown.js +11 -0
- package/test/db.test.js +227 -0
- package/test/entity-engine.test.js +193 -0
- package/test/error-reporter.test.js +140 -0
- package/test/functions-engine.test.js +240 -0
- package/test/groups.test.js +212 -0
- package/test/hot-reload.test.js +153 -0
- package/test/i18n.test.js +173 -0
- package/test/middleware.test.js +76 -0
- package/test/openapi.test.js +67 -0
- package/test/schema-validator.test.js +83 -0
- package/test/sdk.test.js +90 -0
- package/test/seeder.test.js +279 -0
- package/test/settings.test.js +109 -0
- package/test/telemetry.test.js +254 -0
- package/test/test.js +17 -0
- package/test/upload.test.js +265 -0
- package/test/validation.test.js +96 -0
- package/test/yaml-loader.test.js +93 -0
- package/utils/logger.js +24 -0
package/admin/index.html
ADDED
|
@@ -0,0 +1,2802 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" id="html-root">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>ChadStart Admin</title>
|
|
7
|
+
<script src="/admin/vendor/tailwind.js"></script>
|
|
8
|
+
<style type="text/tailwindcss">
|
|
9
|
+
@theme {
|
|
10
|
+
--color-cs-bg: #121212;
|
|
11
|
+
--color-cs-surface: #1e1e1e;
|
|
12
|
+
--color-cs-border: #2a2a2a;
|
|
13
|
+
--color-cs-primary: #34b1eb;
|
|
14
|
+
--color-cs-accent: #03dac6;
|
|
15
|
+
--color-cs-text: #e1e1e1;
|
|
16
|
+
--color-cs-muted: #888888;
|
|
17
|
+
}
|
|
18
|
+
</style>
|
|
19
|
+
<style>
|
|
20
|
+
/* ── Sidebar nav items ─────────────────────────────────────────── */
|
|
21
|
+
.nav-item {
|
|
22
|
+
display: block; padding: 7px 16px; font-size: .875rem; cursor: pointer;
|
|
23
|
+
color: var(--color-cs-muted); border-left: 2px solid transparent;
|
|
24
|
+
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
|
|
25
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
26
|
+
}
|
|
27
|
+
.nav-item:hover { background: rgba(128,128,128,.08); color: var(--color-cs-text); }
|
|
28
|
+
.nav-item.active { background: rgba(52,177,235,.07); border-left-color: #34b1eb; color: #34b1eb; }
|
|
29
|
+
/* ── Thin sidebar scrollbar ────────────────────────────────────── */
|
|
30
|
+
.sidebar-nav { scrollbar-width: thin; scrollbar-color: var(--color-cs-border) transparent; }
|
|
31
|
+
/* ── Config editor tab active state ────────────────────────────── */
|
|
32
|
+
.cfg-tab.active { background: rgba(52,177,235,.12); border-color: #34b1eb; color: #34b1eb; }
|
|
33
|
+
|
|
34
|
+
/* ── Animated glowing border button ────────────────────────────── */
|
|
35
|
+
@property --glow-a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
|
|
36
|
+
@keyframes spin-glow { to { --glow-a: 360deg; } }
|
|
37
|
+
.btn-glow {
|
|
38
|
+
border: 2px solid transparent;
|
|
39
|
+
background:
|
|
40
|
+
linear-gradient(var(--color-cs-surface), var(--color-cs-surface)) padding-box,
|
|
41
|
+
conic-gradient(from var(--glow-a), #34b1eb 0%, #0e7fc4 40%, transparent 60%, #34b1eb 100%) border-box;
|
|
42
|
+
animation: spin-glow 2s linear infinite;
|
|
43
|
+
color: #34b1eb; font-weight: 500;
|
|
44
|
+
transition: opacity 150ms ease;
|
|
45
|
+
}
|
|
46
|
+
.btn-glow:hover { opacity: 0.8; }
|
|
47
|
+
.btn-glow:disabled { opacity: 0.4; animation: none; }
|
|
48
|
+
|
|
49
|
+
/* ── Tab buttons ──────────────────────────────────────────────── */
|
|
50
|
+
.tab-btn { padding: 5px 14px; font-size: 12px; border: 1px solid var(--color-cs-border); border-radius: 4px; cursor: pointer; color: var(--color-cs-muted); background: transparent; transition: all 150ms; }
|
|
51
|
+
.tab-btn.active { border-color: #34b1eb; color: #34b1eb; background: rgba(52,177,235,.08); }
|
|
52
|
+
|
|
53
|
+
/* ── Code block ───────────────────────────────────────────────── */
|
|
54
|
+
.code-block { background: var(--color-cs-bg); border: 1px solid var(--color-cs-border); border-radius: 6px; padding: 12px 14px; font-family: monospace; font-size: 12px; color: var(--color-cs-text); white-space: pre; overflow-x: auto; line-height: 1.6; }
|
|
55
|
+
|
|
56
|
+
/* ── Activity feed ────────────────────────────────────────────── */
|
|
57
|
+
.activity-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
|
|
58
|
+
.dot-create { background: #34b1eb; }
|
|
59
|
+
.dot-update { background: #fbbf24; }
|
|
60
|
+
.dot-delete { background: #f87171; }
|
|
61
|
+
|
|
62
|
+
/* ── Row highlight (vim nav) ──────────────────────────────────── */
|
|
63
|
+
tr.row-hl td { background: rgba(52,177,235,.06) !important; }
|
|
64
|
+
</style>
|
|
65
|
+
<script src="/admin/vendor/cronstrue.min.js"></script>
|
|
66
|
+
</head>
|
|
67
|
+
<body class="bg-cs-bg text-cs-text min-h-screen flex" style="font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;">
|
|
68
|
+
|
|
69
|
+
<!-- ── Mobile sidebar backdrop ────────────────────────────────────────────── -->
|
|
70
|
+
<div id="sidebar-overlay" class="fixed inset-0 bg-black/50 z-30 hidden lg:hidden" onclick="closeSidebar()"></div>
|
|
71
|
+
|
|
72
|
+
<!-- ── Sidebar ────────────────────────────────────────────────────────────── -->
|
|
73
|
+
<aside id="sidebar"
|
|
74
|
+
class="fixed inset-y-0 left-0 z-40 w-60 bg-cs-surface border-r border-cs-border flex flex-col
|
|
75
|
+
-translate-x-full lg:translate-x-0" style="transition: transform 150ms ease;">
|
|
76
|
+
<div class="px-4 py-4 border-b border-cs-border">
|
|
77
|
+
<div id="project-name" class="text-lg font-bold text-cs-text truncate">Loading…</div>
|
|
78
|
+
<div class="text-[10px] text-cs-muted mt-0.5">Powered by ChadStart.com</div>
|
|
79
|
+
</div>
|
|
80
|
+
<nav class="sidebar-nav flex-1 overflow-y-auto py-2">
|
|
81
|
+
<div class="px-4 pt-2 pb-1 text-xs text-cs-muted">Overview</div>
|
|
82
|
+
<div id="nav-dashboard">
|
|
83
|
+
<div class="nav-item" id="nav-dashboard-item" onclick="selectDashboard(this)">📊 Dashboard</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="px-4 pt-3 pb-1 text-xs text-cs-muted border-t border-cs-border mt-2">Collections</div>
|
|
86
|
+
<div id="nav-user-collections"></div>
|
|
87
|
+
<div class="px-4 pt-3 pb-1 text-xs text-cs-muted border-t border-cs-border mt-2">Entities</div>
|
|
88
|
+
<div id="nav-entities"></div>
|
|
89
|
+
<div class="px-4 pt-3 pb-1 text-xs text-cs-muted border-t border-cs-border mt-2">Admin</div>
|
|
90
|
+
<div id="nav-admin">
|
|
91
|
+
<div class="nav-item" id="nav-config-item" onclick="selectConfig(this)" data-i18n="sidebar.config_editor">⚙ Config Editor</div>
|
|
92
|
+
<div class="nav-item" id="nav-apikeys-item" onclick="selectApiKeys(this)" data-i18n="sidebar.api_keys">🔑 API Keys</div>
|
|
93
|
+
|
|
94
|
+
</div>
|
|
95
|
+
</nav>
|
|
96
|
+
<div class="border-t border-cs-border px-4 py-3">
|
|
97
|
+
<div class="flex items-center gap-2 mb-3">
|
|
98
|
+
<div id="user-avatar"
|
|
99
|
+
class="w-7 h-7 rounded bg-cs-border flex items-center justify-center font-medium text-xs text-cs-text shrink-0">?</div>
|
|
100
|
+
<div class="min-w-0 flex-1">
|
|
101
|
+
<div id="user-name" class="text-sm text-cs-text truncate">–</div>
|
|
102
|
+
<div id="user-collection-label" class="text-xs text-cs-muted truncate">–</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<button onclick="logout()"
|
|
106
|
+
class="w-full text-sm text-cs-muted border border-cs-border rounded py-1.5 px-3
|
|
107
|
+
hover:border-red-500/50 hover:text-red-400" style="transition: color 150ms ease, border-color 150ms ease;"
|
|
108
|
+
data-i18n="sidebar.log_out">Log out</button>
|
|
109
|
+
</div>
|
|
110
|
+
</aside>
|
|
111
|
+
|
|
112
|
+
<!-- ── Main ───────────────────────────────────────────────────────────────── -->
|
|
113
|
+
<div id="main" class="flex-1 flex flex-col min-w-0 lg:ml-60">
|
|
114
|
+
|
|
115
|
+
<!-- Header -->
|
|
116
|
+
<header class="flex items-center justify-between px-5 py-3 border-b border-cs-border bg-cs-surface sticky top-0 z-20">
|
|
117
|
+
<div class="flex items-center gap-3">
|
|
118
|
+
<button class="lg:hidden text-cs-muted hover:text-cs-text" onclick="openSidebar()" aria-label="Menu" style="transition: color 150ms ease;">
|
|
119
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
120
|
+
</button>
|
|
121
|
+
<span id="page-title" class="font-medium text-sm text-cs-text">Dashboard</span>
|
|
122
|
+
</div>
|
|
123
|
+
<div id="header-actions" class="flex items-center gap-2">
|
|
124
|
+
<!-- Table-only buttons -->
|
|
125
|
+
<button onclick="openThemeSettings()" title="Theme Settings"
|
|
126
|
+
class="py-1.5 px-3 rounded text-sm cursor-pointer border border-cs-border text-cs-muted hover:text-cs-text" style="transition: color 150ms,background 150ms;">🎨 Theme</button>
|
|
127
|
+
<button id="btn-share" onclick="shareView()" title="Share current view"
|
|
128
|
+
class="hidden py-1.5 px-3 rounded text-sm cursor-pointer border border-cs-border text-cs-muted hover:text-cs-text" style="transition: color 150ms,background 150ms;">🔗 Share</button>
|
|
129
|
+
<button id="btn-devs" onclick="openDevsModal()"
|
|
130
|
+
class="hidden py-1.5 px-3 rounded text-sm cursor-pointer border border-cs-border text-cs-muted hover:text-cs-text" style="transition: color 150ms,background 150ms;"></> Developers</button>
|
|
131
|
+
<button id="btn-seed" onclick="openSeedModal()"
|
|
132
|
+
class="py-1.5 px-3 rounded text-sm cursor-pointer border border-cs-border text-cs-muted hover:text-cs-text" style="transition: color 150ms,background 150ms;">🌱 Seed</button>
|
|
133
|
+
<button onclick="openShortcutsModal()" title="Keyboard shortcuts (?)"
|
|
134
|
+
class="py-1.5 px-2 rounded text-sm cursor-pointer border border-cs-border text-cs-muted hover:text-cs-text" style="transition: color 150ms,background 150ms;">?</button>
|
|
135
|
+
<button id="btn-add" onclick="openAddModal()"
|
|
136
|
+
class="btn-glow hidden py-1.5 px-3 rounded text-sm cursor-pointer">+ New record</button>
|
|
137
|
+
<!-- AI chat button — hidden until /admin/ai/status says configured -->
|
|
138
|
+
<button id="btn-ai" onclick="toggleAiPanel()"
|
|
139
|
+
class="hidden items-center gap-1.5 py-1.5 px-3 rounded text-sm cursor-pointer border border-cs-border text-cs-muted hover:text-cs-text hover:bg-cs-border"
|
|
140
|
+
style="transition: background 150ms ease, color 150ms ease;" title="Ask AI assistant">
|
|
141
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
142
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
143
|
+
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
|
144
|
+
</svg>
|
|
145
|
+
Ask AI
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
</header>
|
|
149
|
+
|
|
150
|
+
<main class="p-5 flex-1 overflow-auto">
|
|
151
|
+
|
|
152
|
+
<!-- Loading spinner -->
|
|
153
|
+
<div id="loading" class="hidden flex justify-center py-16">
|
|
154
|
+
<div class="w-6 h-6 border-2 border-cs-border border-t-cs-primary rounded-full animate-spin"></div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- ── Dashboard ──────────────────────────────────────────────────── -->
|
|
158
|
+
<div id="dashboard-area" class="hidden">
|
|
159
|
+
<div id="stats-grid" class="grid gap-4 mb-6" style="grid-template-columns: repeat(auto-fill, minmax(180px,1fr));"></div>
|
|
160
|
+
<div class="grid gap-4" style="grid-template-columns: 1fr 1fr;">
|
|
161
|
+
<div class="bg-cs-surface border border-cs-border rounded p-4">
|
|
162
|
+
<div class="text-sm font-medium text-cs-text mb-3 flex items-center gap-2">
|
|
163
|
+
<span>📡 Live Activity</span>
|
|
164
|
+
<span id="ws-badge" class="text-[10px] px-1.5 py-0.5 rounded" style="background:rgba(52,177,235,.12);color:#34b1eb;">connecting…</span>
|
|
165
|
+
</div>
|
|
166
|
+
<div id="activity-feed" class="space-y-0 max-h-80 overflow-y-auto text-xs"></div>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="bg-cs-surface border border-cs-border rounded p-4">
|
|
169
|
+
<div class="text-sm font-medium text-cs-text mb-3">📈 Entity Summary</div>
|
|
170
|
+
<div id="entity-summary"></div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<!-- ── Table view ──────────────────────────────────────────────────── -->
|
|
176
|
+
<div id="table-view" class="hidden">
|
|
177
|
+
<!-- Search & filter toolbar -->
|
|
178
|
+
<div id="search-toolbar" class="mb-4">
|
|
179
|
+
<div class="flex gap-2 flex-wrap items-center mb-2">
|
|
180
|
+
<input id="search-input" type="search" placeholder="Search…"
|
|
181
|
+
class="bg-cs-bg border border-cs-border rounded text-cs-text px-3 py-1.5 text-sm outline-none focus:border-cs-primary flex-1 min-w-40"
|
|
182
|
+
style="transition: border-color 150ms;" oninput="onSearchInput()" />
|
|
183
|
+
<button onclick="toggleAdvancedSearch()" id="btn-adv"
|
|
184
|
+
class="py-1.5 px-3 text-sm border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer whitespace-nowrap" style="transition: color 150ms;">⚙ Filters</button>
|
|
185
|
+
<select id="order-by-sel" onchange="onOrderChange()"
|
|
186
|
+
class="bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none cursor-pointer">
|
|
187
|
+
<option value="createdAt">Created</option>
|
|
188
|
+
<option value="updatedAt">Updated</option>
|
|
189
|
+
<option value="id">ID</option>
|
|
190
|
+
</select>
|
|
191
|
+
<select id="order-dir-sel" onchange="onOrderChange()"
|
|
192
|
+
class="bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none cursor-pointer">
|
|
193
|
+
<option value="DESC">↓ Desc</option>
|
|
194
|
+
<option value="ASC">↑ Asc</option>
|
|
195
|
+
</select>
|
|
196
|
+
<select id="per-page-sel" onchange="onPerPageChange()"
|
|
197
|
+
class="bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none cursor-pointer">
|
|
198
|
+
<option value="10">10/pg</option>
|
|
199
|
+
<option value="20" selected>20/pg</option>
|
|
200
|
+
<option value="50">50/pg</option>
|
|
201
|
+
<option value="100">100/pg</option>
|
|
202
|
+
</select>
|
|
203
|
+
<button onclick="exportData('csv')" title="Export CSV"
|
|
204
|
+
class="py-1.5 px-3 text-sm border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer whitespace-nowrap" style="transition: color 150ms;">↓ CSV</button>
|
|
205
|
+
<button onclick="exportData('json')" title="Export JSON"
|
|
206
|
+
class="py-1.5 px-3 text-sm border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer whitespace-nowrap" style="transition: color 150ms;">↓ JSON</button>
|
|
207
|
+
<button id="btn-columns" onclick="openColumnsPanel()" title="Customize columns"
|
|
208
|
+
class="py-1.5 px-3 text-sm border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer whitespace-nowrap" style="transition: color 150ms;">⊞ Columns</button>
|
|
209
|
+
</div>
|
|
210
|
+
<!-- Advanced filter panel -->
|
|
211
|
+
<div id="adv-panel" class="hidden bg-cs-surface border border-cs-border rounded p-3 mb-2">
|
|
212
|
+
<div class="text-xs text-cs-muted mb-2">Advanced Filters</div>
|
|
213
|
+
<div id="adv-filter-list" class="space-y-2"></div>
|
|
214
|
+
<div class="flex gap-2 mt-2">
|
|
215
|
+
<button onclick="addAdvFilter()" class="text-xs border border-cs-border rounded px-2 py-1 text-cs-muted hover:text-cs-text cursor-pointer">+ Add filter</button>
|
|
216
|
+
<button onclick="applyAdvFilters()" class="text-xs border rounded px-2 py-1 cursor-pointer" style="border-color:#34b1eb;color:#34b1eb;">Apply</button>
|
|
217
|
+
<button onclick="clearAdvFilters()" class="text-xs border border-cs-border rounded px-2 py-1 text-cs-muted hover:text-cs-text cursor-pointer">Clear</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<!-- Bulk actions bar -->
|
|
221
|
+
<div id="bulk-bar" class="hidden flex items-center gap-3 bg-cs-surface border border-cs-border rounded px-3 py-2 mb-2">
|
|
222
|
+
<span id="bulk-count" class="text-sm text-cs-text">0 selected</span>
|
|
223
|
+
<button onclick="bulkDelete()" class="text-xs border rounded px-2 py-1 cursor-pointer" style="border-color:rgba(239,68,68,.4);color:#f87171;">Delete selected</button>
|
|
224
|
+
<button onclick="clearSelection()" class="text-xs border border-cs-border rounded px-2 py-1 text-cs-muted hover:text-cs-text cursor-pointer">Clear</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div id="table-area"></div>
|
|
229
|
+
|
|
230
|
+
<!-- Pagination -->
|
|
231
|
+
<div id="pagination" class="hidden flex items-center justify-between mt-4 flex-wrap gap-2">
|
|
232
|
+
<span id="pg-info" class="text-xs text-cs-muted"></span>
|
|
233
|
+
<div class="flex items-center gap-1">
|
|
234
|
+
<button onclick="goPage(1)" id="pg-first" class="px-2 py-1 text-xs border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer">«</button>
|
|
235
|
+
<button onclick="goPage(curPage-1)" id="pg-prev" class="px-2 py-1 text-xs border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer">‹</button>
|
|
236
|
+
<span id="pg-btns" class="flex gap-1"></span>
|
|
237
|
+
<button onclick="goPage(curPage+1)" id="pg-next" class="px-2 py-1 text-xs border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer">›</button>
|
|
238
|
+
<button onclick="goPage(curLastPage)" id="pg-last" class="px-2 py-1 text-xs border border-cs-border rounded text-cs-muted hover:text-cs-text cursor-pointer">»</button>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<!-- ── API Keys Panel ────────────────────────────────────────── -->
|
|
244
|
+
<div id="api-keys-area" class="hidden">
|
|
245
|
+
<div class="flex items-center justify-between mb-4">
|
|
246
|
+
<p class="text-xs" style="color:var(--color-cs-muted);">Manage API keys for programmatic access. Keys are shown only at creation time.</p>
|
|
247
|
+
<button onclick="openCreateApiKeyModal()"
|
|
248
|
+
class="text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer" style="transition:color 150ms,background 150ms;">+ Create API Key</button>
|
|
249
|
+
</div>
|
|
250
|
+
<div id="api-keys-list">
|
|
251
|
+
<div class="flex justify-center py-8"><div class="w-5 h-5 border-2 border-cs-border border-t-cs-primary rounded-full animate-spin"></div></div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<!-- ── Config Editor ─────────────────────────────────────────── -->
|
|
256
|
+
<div id="config-area" class="hidden">
|
|
257
|
+
<div class="flex gap-1 flex-wrap mb-4 border-b border-cs-border pb-3">
|
|
258
|
+
<button onclick="showConfigTab('general')" id="cfg-tab-general" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.general">General</button>
|
|
259
|
+
<button onclick="showConfigTab('entities')" id="cfg-tab-entities" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.entities">Entities</button>
|
|
260
|
+
<button onclick="showConfigTab('functions')" id="cfg-tab-functions" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.functions">Functions</button>
|
|
261
|
+
<button onclick="showConfigTab('files')" id="cfg-tab-files" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.files">Files & Uploads</button>
|
|
262
|
+
<button onclick="showConfigTab('settings')" id="cfg-tab-settings" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.settings">Settings</button>
|
|
263
|
+
<button onclick="showConfigTab('all')" id="cfg-tab-all" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.all">All</button>
|
|
264
|
+
</div>
|
|
265
|
+
<p id="cfg-section-desc" class="text-xs mb-3" style="color:var(--color-cs-muted);"></p>
|
|
266
|
+
<div id="cfg-form" class="hidden mb-6"></div>
|
|
267
|
+
<div id="cfg-json-section" class="hidden">
|
|
268
|
+
<div class="flex items-center justify-between mb-2">
|
|
269
|
+
<span class="text-xs font-medium" style="color:var(--color-cs-muted);" id="cfg-json-label">Full Configuration JSON</span>
|
|
270
|
+
<span class="text-xs" style="color:var(--color-cs-muted);" id="cfg-json-hint"></span>
|
|
271
|
+
</div>
|
|
272
|
+
<textarea id="cfg-editor"
|
|
273
|
+
class="w-full bg-cs-bg border border-cs-border rounded text-cs-text px-3 py-2.5 text-xs font-mono outline-none focus:border-cs-primary resize-y"
|
|
274
|
+
style="min-height:300px;transition:border-color 150ms;line-height:1.6;tab-size:2;"
|
|
275
|
+
spellcheck="false"></textarea>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="flex items-center gap-3 mt-4">
|
|
278
|
+
<button onclick="saveConfig()" id="cfg-save-btn"
|
|
279
|
+
class="py-1.5 px-4 rounded text-sm cursor-pointer font-medium hover:opacity-90 disabled:opacity-50"
|
|
280
|
+
style="background:#34b1eb;color:#121212;transition:opacity 150ms;">Save Config</button>
|
|
281
|
+
<button onclick="resetConfigTab()"
|
|
282
|
+
class="text-sm border border-cs-border rounded py-1.5 px-4 text-cs-muted hover:text-cs-text hover:bg-cs-border" style="transition:color 150ms,background 150ms;"
|
|
283
|
+
data-i18n="config.reset_btn">Reset</button>
|
|
284
|
+
<span id="cfg-status" class="text-xs" style="color:var(--color-cs-muted);"></span>
|
|
285
|
+
</div>
|
|
286
|
+
<div id="cfg-s3-note" class="hidden mt-4 p-3 rounded border text-xs" style="border-color:var(--color-cs-border);color:var(--color-cs-muted);">
|
|
287
|
+
<strong style="color:var(--color-cs-text);">S3 Storage</strong> is configured via environment variables:<br>
|
|
288
|
+
<code style="color:#34b1eb;">S3_BUCKET</code>, <code style="color:#34b1eb;">S3_ENDPOINT</code>,
|
|
289
|
+
<code style="color:#34b1eb;">S3_REGION</code>, <code style="color:#34b1eb;">S3_ACCESS_KEY_ID</code>,
|
|
290
|
+
<code style="color:#34b1eb;">S3_SECRET_ACCESS_KEY</code>.<br>
|
|
291
|
+
Set these in your <code style="color:#34b1eb;">.env</code> file or deployment environment.
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</main>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<!-- ── Toast ──────────────────────────────────────────────────────────────── -->
|
|
298
|
+
<div id="toast" class="hidden fixed bottom-6 right-6 z-50"></div>
|
|
299
|
+
|
|
300
|
+
<!-- ── Theme Settings modal ───────────────────────────────────────────────── -->
|
|
301
|
+
<div id="theme-modal-backdrop" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
302
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-sm" style="box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
|
303
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
304
|
+
<span class="font-medium text-sm">🎨 Theme Settings</span>
|
|
305
|
+
<button onclick="closeThemeSettings()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease;">×</button>
|
|
306
|
+
</div>
|
|
307
|
+
<div class="p-5 space-y-5">
|
|
308
|
+
<!-- Dark/Light mode -->
|
|
309
|
+
<div>
|
|
310
|
+
<div class="text-xs text-cs-muted mb-2">Mode</div>
|
|
311
|
+
<div class="flex gap-2">
|
|
312
|
+
<button id="theme-dark-btn" onclick="setThemeMode('dark')"
|
|
313
|
+
class="flex-1 py-2 text-sm rounded border cursor-pointer" style="transition: all 150ms;">Dark</button>
|
|
314
|
+
<button id="theme-light-btn" onclick="setThemeMode('light')"
|
|
315
|
+
class="flex-1 py-2 text-sm rounded border cursor-pointer" style="transition: all 150ms;">Light</button>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
<!-- Primary color -->
|
|
319
|
+
<div>
|
|
320
|
+
<div class="text-xs text-cs-muted mb-2">Primary Color</div>
|
|
321
|
+
<div class="flex flex-wrap gap-2 mb-2" id="theme-palette">
|
|
322
|
+
<!-- populated by JS -->
|
|
323
|
+
</div>
|
|
324
|
+
<div class="flex items-center gap-2">
|
|
325
|
+
<input type="color" id="theme-custom-color" value="#34b1eb"
|
|
326
|
+
class="w-8 h-8 rounded cursor-pointer border border-cs-border bg-cs-bg"
|
|
327
|
+
style="padding: 1px;" oninput="onCustomColorInput(this.value)" />
|
|
328
|
+
<span class="text-xs text-cs-muted">Custom color</span>
|
|
329
|
+
<span id="theme-custom-hex" class="text-xs font-mono" style="color:var(--color-cs-muted);">#34b1eb</span>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<!-- ── Columns visibility modal ───────────────────────────────────────────── -->
|
|
337
|
+
<div id="columns-modal-backdrop" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
338
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-xs" style="box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
|
339
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
340
|
+
<span class="font-medium text-sm">⊞ Visible Columns</span>
|
|
341
|
+
<button onclick="closeColumnsPanel()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease;">×</button>
|
|
342
|
+
</div>
|
|
343
|
+
<div class="p-4">
|
|
344
|
+
<p class="text-xs text-cs-muted mb-3">Choose which columns to show for <strong id="columns-entity-name" class="text-cs-text"></strong>.</p>
|
|
345
|
+
<div id="columns-list" class="space-y-1.5 max-h-60 overflow-y-auto mb-4"></div>
|
|
346
|
+
<div class="flex gap-2">
|
|
347
|
+
<button onclick="saveColumnsPrefs()"
|
|
348
|
+
class="flex-1 text-sm py-1.5 rounded cursor-pointer font-medium hover:opacity-90"
|
|
349
|
+
style="background:#34b1eb;color:#121212;transition:opacity 150ms;">Apply</button>
|
|
350
|
+
<button onclick="resetColumnsPrefs()"
|
|
351
|
+
class="text-sm border border-cs-border rounded py-1.5 px-3 text-cs-muted hover:text-cs-text cursor-pointer" style="transition: color 150ms, background 150ms;">Reset</button>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<!-- ── Record lookup modal ─────────────────────────────────────────────────── -->
|
|
358
|
+
<div id="lookup-modal-backdrop" class="fixed inset-0 bg-black/60 z-100 flex items-center justify-center p-4 hidden">
|
|
359
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-md max-h-[80vh] flex flex-col" style="box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
|
360
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
361
|
+
<span id="lookup-modal-title" class="font-medium text-sm">Record</span>
|
|
362
|
+
<button onclick="closeLookupModal()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease;">×</button>
|
|
363
|
+
</div>
|
|
364
|
+
<div id="lookup-modal-body" class="p-5 overflow-y-auto flex-1 text-xs font-mono" style="color:var(--color-cs-text);white-space:pre;"></div>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<!-- ── ID Picker modal ──────────────────────────────────────────────────────── -->
|
|
369
|
+
<div id="id-picker-backdrop" class="fixed inset-0 bg-black/60 z-100 flex items-center justify-center p-4 hidden">
|
|
370
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-lg max-h-[80vh] flex flex-col" style="box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
|
371
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
372
|
+
<span id="id-picker-title" class="font-medium text-sm">Select Record</span>
|
|
373
|
+
<button onclick="closeIdPicker()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease;">×</button>
|
|
374
|
+
</div>
|
|
375
|
+
<div class="px-4 py-2 border-b border-cs-border">
|
|
376
|
+
<input id="id-picker-search" type="search" placeholder="Search…"
|
|
377
|
+
class="w-full bg-cs-bg border border-cs-border rounded text-cs-text px-3 py-1.5 text-sm outline-none focus:border-cs-primary"
|
|
378
|
+
style="transition: border-color 150ms;" oninput="onIdPickerSearch()" />
|
|
379
|
+
</div>
|
|
380
|
+
<div id="id-picker-list" class="overflow-y-auto flex-1 p-2 space-y-0.5"></div>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<!-- ── Impersonate modal ──────────────────────────────────────────────── -->
|
|
385
|
+
<div id="impersonate-modal-backdrop" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
386
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-md" style="box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
|
387
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
388
|
+
<span class="font-medium text-sm">Impersonate User</span>
|
|
389
|
+
<button onclick="closeImpersonateModal()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease;">×</button>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="p-5">
|
|
392
|
+
<p class="text-xs mb-3" style="color:var(--color-cs-muted);">A short-lived token (1 hour) has been generated for this user. Use it to test API requests as this user.</p>
|
|
393
|
+
<div id="impersonate-user-info" class="mb-3 text-xs" style="color:var(--color-cs-muted);"></div>
|
|
394
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">Token</label>
|
|
395
|
+
<textarea id="impersonate-token" readonly
|
|
396
|
+
class="w-full bg-cs-bg border border-cs-border rounded text-cs-text px-3 py-2 text-xs font-mono outline-none resize-none"
|
|
397
|
+
style="height:80px;line-height:1.5;" spellcheck="false"></textarea>
|
|
398
|
+
<div class="flex gap-2 mt-3">
|
|
399
|
+
<button onclick="copyImpersonateToken()"
|
|
400
|
+
class="text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer" style="transition:color 150ms,background 150ms;">Copy Token</button>
|
|
401
|
+
<button onclick="closeImpersonateModal()"
|
|
402
|
+
class="text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer" style="transition:color 150ms,background 150ms;">Close</button>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<!-- ── Create API Key modal ───────────────────────────────────────────── -->
|
|
409
|
+
<div id="apikey-modal-backdrop" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
410
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-lg" style="box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
|
411
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
412
|
+
<span class="font-medium text-sm" id="apikey-modal-title">Create API Key</span>
|
|
413
|
+
<button onclick="closeApiKeyModal()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease;">×</button>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="p-5 space-y-4">
|
|
416
|
+
<!-- Result (shown after creation) -->
|
|
417
|
+
<div id="apikey-result" class="hidden">
|
|
418
|
+
<p class="text-xs mb-2" style="color:#03dac6;">✓ API key created. Copy it now — it will not be shown again.</p>
|
|
419
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">Your API Key</label>
|
|
420
|
+
<div class="flex gap-2">
|
|
421
|
+
<input type="text" id="apikey-result-value" readonly
|
|
422
|
+
class="flex-1 bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-xs font-mono outline-none" />
|
|
423
|
+
<button onclick="copyApiKey()"
|
|
424
|
+
class="text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer whitespace-nowrap" style="transition:color 150ms,background 150ms;">Copy</button>
|
|
425
|
+
</div>
|
|
426
|
+
<button onclick="closeApiKeyModal()" class="mt-3 text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer" style="transition:color 150ms,background 150ms;">Close</button>
|
|
427
|
+
</div>
|
|
428
|
+
<!-- Form (shown before creation) -->
|
|
429
|
+
<div id="apikey-form">
|
|
430
|
+
<div class="space-y-3">
|
|
431
|
+
<div>
|
|
432
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">Name</label>
|
|
433
|
+
<input type="text" id="apikey-name" placeholder="My API Key"
|
|
434
|
+
class="w-full bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none focus:border-cs-primary" style="transition:border-color 150ms;" />
|
|
435
|
+
</div>
|
|
436
|
+
<div>
|
|
437
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">User (for admin creation)</label>
|
|
438
|
+
<select id="apikey-user-entity" class="w-full bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none focus:border-cs-primary" style="transition:border-color 150ms;">
|
|
439
|
+
<option value="">— Create for myself —</option>
|
|
440
|
+
</select>
|
|
441
|
+
</div>
|
|
442
|
+
<div id="apikey-user-id-row" class="hidden">
|
|
443
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">User ID</label>
|
|
444
|
+
<input type="text" id="apikey-user-id" placeholder="user-uuid"
|
|
445
|
+
class="w-full bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none focus:border-cs-primary" style="transition:border-color 150ms;" />
|
|
446
|
+
</div>
|
|
447
|
+
<div>
|
|
448
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">Permissions <span style="color:var(--color-cs-muted);">(leave empty for full access)</span></label>
|
|
449
|
+
<div class="flex flex-wrap gap-3">
|
|
450
|
+
<label class="flex items-center gap-1.5 text-xs cursor-pointer select-none" style="color:var(--color-cs-muted);"><input type="checkbox" class="apikey-perm" value="create" /> create</label>
|
|
451
|
+
<label class="flex items-center gap-1.5 text-xs cursor-pointer select-none" style="color:var(--color-cs-muted);"><input type="checkbox" class="apikey-perm" value="read" /> read</label>
|
|
452
|
+
<label class="flex items-center gap-1.5 text-xs cursor-pointer select-none" style="color:var(--color-cs-muted);"><input type="checkbox" class="apikey-perm" value="update" /> update</label>
|
|
453
|
+
<label class="flex items-center gap-1.5 text-xs cursor-pointer select-none" style="color:var(--color-cs-muted);"><input type="checkbox" class="apikey-perm" value="delete" /> delete</label>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
<div>
|
|
457
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">Entity access <span style="color:var(--color-cs-muted);">(leave empty for all entities)</span></label>
|
|
458
|
+
<div id="apikey-entities-list" class="flex flex-wrap gap-2"></div>
|
|
459
|
+
</div>
|
|
460
|
+
<div>
|
|
461
|
+
<label class="block text-xs mb-1" style="color:var(--color-cs-muted);">Expiration date <span style="color:var(--color-cs-muted);">(optional)</span></label>
|
|
462
|
+
<input type="datetime-local" id="apikey-expires"
|
|
463
|
+
class="bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none focus:border-cs-primary" style="transition:border-color 150ms;" />
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
<div id="apikey-form-error" class="hidden text-red-400 text-xs mt-2"></div>
|
|
467
|
+
<div class="flex gap-2 mt-4">
|
|
468
|
+
<button onclick="submitCreateApiKey()"
|
|
469
|
+
class="text-sm py-1.5 px-4 rounded cursor-pointer bg-cs-primary text-cs-bg font-medium hover:opacity-90 disabled:opacity-50" id="apikey-submit-btn" style="transition:opacity 150ms;">Create Key</button>
|
|
470
|
+
<button onclick="closeApiKeyModal()"
|
|
471
|
+
class="text-sm border border-cs-border rounded py-1.5 px-4 text-cs-muted hover:text-cs-text hover:bg-cs-border cursor-pointer" style="transition:color 150ms,background 150ms;">Cancel</button>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<!-- ── Record modal ───────────────────────────────────────────────────── -->
|
|
479
|
+
<div id="modal-backdrop" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
480
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-lg max-h-[90vh] flex flex-col" style="box-shadow: 0 2px 8px rgba(0,0,0,.3);">
|
|
481
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
482
|
+
<span id="modal-title" class="font-medium text-sm">New record</span>
|
|
483
|
+
<button onclick="closeModal()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease;">×</button>
|
|
484
|
+
</div>
|
|
485
|
+
<div id="modal-body" class="p-5 overflow-y-auto flex-1"></div>
|
|
486
|
+
<div class="flex justify-end gap-2 px-5 py-3 border-t border-cs-border">
|
|
487
|
+
<button onclick="closeModal()"
|
|
488
|
+
class="text-sm border border-cs-border rounded py-1.5 px-4 text-cs-muted hover:text-cs-text hover:bg-cs-border" style="transition: color 150ms ease, background 150ms ease;"
|
|
489
|
+
data-i18n="modal.cancel">Cancel</button>
|
|
490
|
+
<button id="modal-save-btn" onclick="saveRecord()"
|
|
491
|
+
class="py-1.5 px-4 rounded text-sm cursor-pointer font-medium hover:opacity-90 disabled:opacity-50"
|
|
492
|
+
style="background:#34b1eb;color:#121212;transition:opacity 150ms;"><span>Save</span></button>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<!-- ── Developers modal ───────────────────────────────────────────────────── -->
|
|
498
|
+
<div id="devs-modal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
499
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-2xl max-h-[90vh] flex flex-col" style="box-shadow: 0 2px 8px rgba(0,0,0,.3);">
|
|
500
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
501
|
+
<span class="font-medium text-sm"></> Developer Examples</span>
|
|
502
|
+
<button onclick="closeDevsModal()" class="text-cs-muted hover:text-cs-text text-xl leading-none">×</button>
|
|
503
|
+
</div>
|
|
504
|
+
<div class="px-5 pt-3 pb-2 flex gap-2 border-b border-cs-border">
|
|
505
|
+
<button class="tab-btn active" id="devs-sdk-tab" onclick="switchDevsTab('sdk')">JS SDK</button>
|
|
506
|
+
<button class="tab-btn" id="devs-rest-tab" onclick="switchDevsTab('rest')">cURL</button>
|
|
507
|
+
</div>
|
|
508
|
+
<div id="devs-body" class="p-5 overflow-y-auto flex-1"></div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
<!-- ── Seed modal ─────────────────────────────────────────────────────────── -->
|
|
513
|
+
<div id="seed-modal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
514
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-md max-h-[90vh] flex flex-col" style="box-shadow: 0 2px 8px rgba(0,0,0,.3);">
|
|
515
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
516
|
+
<span class="font-medium text-sm">🌱 Seed Database</span>
|
|
517
|
+
<button onclick="closeSeedModal()" class="text-cs-muted hover:text-cs-text text-xl leading-none">×</button>
|
|
518
|
+
</div>
|
|
519
|
+
<div class="p-5 overflow-y-auto flex-1">
|
|
520
|
+
<p class="text-xs text-cs-muted mb-4">Choose entities and how many fake records to generate.</p>
|
|
521
|
+
<div id="seed-list" class="space-y-2 mb-4"></div>
|
|
522
|
+
<div id="seed-result" class="hidden text-xs mt-2" style="color:#34b1eb;"></div>
|
|
523
|
+
</div>
|
|
524
|
+
<div class="flex justify-end gap-2 px-5 py-3 border-t border-cs-border">
|
|
525
|
+
<button onclick="closeSeedModal()" class="text-sm border border-cs-border rounded py-1.5 px-4 text-cs-muted hover:text-cs-text">Cancel</button>
|
|
526
|
+
<button id="seed-btn" onclick="doSeed()" class="py-1.5 px-4 rounded text-sm cursor-pointer font-medium hover:opacity-90" style="background:#34b1eb;color:#121212;transition:opacity 150ms;">Seed</button>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<!-- ── Keyboard shortcuts modal ───────────────────────────────────────────── -->
|
|
532
|
+
<div id="shortcuts-modal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 hidden">
|
|
533
|
+
<div class="bg-cs-surface border border-cs-border rounded w-full max-w-sm" style="box-shadow: 0 2px 8px rgba(0,0,0,.3);">
|
|
534
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-cs-border">
|
|
535
|
+
<span class="font-medium text-sm">⌨ Keyboard Shortcuts</span>
|
|
536
|
+
<button onclick="closeShortcutsModal()" class="text-cs-muted hover:text-cs-text text-xl leading-none">×</button>
|
|
537
|
+
</div>
|
|
538
|
+
<div class="p-5">
|
|
539
|
+
<table class="w-full text-xs">
|
|
540
|
+
<tbody>
|
|
541
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">j / k</kbd></td><td class="py-1 text-cs-muted">Navigate rows down / up</td></tr>
|
|
542
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">/</kbd></td><td class="py-1 text-cs-muted">Focus search</td></tr>
|
|
543
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">n</kbd></td><td class="py-1 text-cs-muted">New record</td></tr>
|
|
544
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">e</kbd></td><td class="py-1 text-cs-muted">Edit highlighted row</td></tr>
|
|
545
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">d</kbd></td><td class="py-1 text-cs-muted">Delete highlighted row</td></tr>
|
|
546
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">] / [</kbd></td><td class="py-1 text-cs-muted">Next / previous page</td></tr>
|
|
547
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">g g</kbd></td><td class="py-1 text-cs-muted">First page</td></tr>
|
|
548
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">G</kbd></td><td class="py-1 text-cs-muted">Last page</td></tr>
|
|
549
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">Esc</kbd></td><td class="py-1 text-cs-muted">Close modal / blur</td></tr>
|
|
550
|
+
<tr><td class="py-1 pr-6"><kbd class="bg-cs-bg border border-cs-border rounded px-1.5 py-0.5 font-mono">?</kbd></td><td class="py-1 text-cs-muted">Show this help</td></tr>
|
|
551
|
+
</tbody>
|
|
552
|
+
</table>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
<!-- ── AI Chat Panel ──────────────────────────────────────────────────── -->
|
|
558
|
+
<div id="ai-panel"
|
|
559
|
+
class="fixed inset-y-0 right-0 z-50 w-full max-w-sm bg-cs-surface border-l border-cs-border flex flex-col"
|
|
560
|
+
style="translate: 100%; transition: translate 200ms ease; box-shadow: -2px 0 12px rgba(0,0,0,0.3);">
|
|
561
|
+
|
|
562
|
+
<!-- Panel header -->
|
|
563
|
+
<div class="flex items-center justify-between px-4 py-3 border-b border-cs-border shrink-0">
|
|
564
|
+
<div class="flex items-center gap-2">
|
|
565
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="color:#bb86fc;">
|
|
566
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
567
|
+
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
|
568
|
+
</svg>
|
|
569
|
+
<span class="font-medium text-sm text-cs-text">AI Assistant</span>
|
|
570
|
+
</div>
|
|
571
|
+
<div class="flex items-center gap-2">
|
|
572
|
+
<button onclick="clearAiChat()" class="text-xs text-cs-muted hover:text-cs-text" style="transition: color 150ms ease; background: none; border: none; cursor: pointer;" title="Clear chat">Clear</button>
|
|
573
|
+
<button onclick="closeAiPanel()" class="text-cs-muted hover:text-cs-text text-xl leading-none" style="transition: color 150ms ease; background: none; border: none; cursor: pointer;" aria-label="Close">×</button>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
|
|
577
|
+
<!-- Messages area -->
|
|
578
|
+
<div id="ai-messages" class="flex-1 overflow-y-auto p-4 space-y-3" style="scrollbar-width: thin; scrollbar-color: #2a2a2a transparent;"></div>
|
|
579
|
+
|
|
580
|
+
<!-- Input area -->
|
|
581
|
+
<div class="shrink-0 border-t border-cs-border p-3">
|
|
582
|
+
<div class="flex gap-2">
|
|
583
|
+
<textarea id="ai-input" rows="2" placeholder="Ask anything about your data, API, or config…"
|
|
584
|
+
class="flex-1 bg-cs-bg border border-cs-border rounded text-cs-text px-3 py-2 text-sm outline-none focus:border-cs-primary resize-none"
|
|
585
|
+
style="transition: border-color 150ms ease; line-height: 1.4;"
|
|
586
|
+
onkeydown="aiInputKeydown(event)"></textarea>
|
|
587
|
+
<button id="ai-send-btn" onclick="sendAiMessage()"
|
|
588
|
+
class="py-2 px-3 rounded text-sm cursor-pointer bg-cs-primary text-cs-bg font-medium hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
|
589
|
+
style="transition: opacity 150ms ease; align-self: flex-end;">Send</button>
|
|
590
|
+
</div>
|
|
591
|
+
<p class="text-xs mt-1.5 text-cs-muted">Shift+Enter for new line · Enter to send</p>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
<script>
|
|
595
|
+
|
|
596
|
+
// ── i18n ───────────────────────────────────────────────────────────────
|
|
597
|
+
let i18n = {}, currentLang = 'en';
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Load locale JSON from /admin/i18n/<lang>.
|
|
601
|
+
* Falls back to English when the requested language is unavailable.
|
|
602
|
+
*/
|
|
603
|
+
async function loadI18n(lang) {
|
|
604
|
+
currentLang = lang;
|
|
605
|
+
try {
|
|
606
|
+
const res = await fetch(`/admin/i18n/${encodeURIComponent(lang)}`);
|
|
607
|
+
if (res.ok) { i18n = await res.json(); applyI18n(); return; }
|
|
608
|
+
} catch { /* ignore */ }
|
|
609
|
+
if (lang !== 'en') {
|
|
610
|
+
try {
|
|
611
|
+
const res = await fetch('/admin/i18n/en');
|
|
612
|
+
if (res.ok) { i18n = await res.json(); currentLang = 'en'; }
|
|
613
|
+
} catch { /* ignore */ }
|
|
614
|
+
}
|
|
615
|
+
applyI18n();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Translate a dot-separated key, optionally interpolating {{param}} placeholders.
|
|
620
|
+
* Falls back to the key itself when the translation is missing.
|
|
621
|
+
*
|
|
622
|
+
* @param {string} key Dot-separated translation key, e.g. "login.sign_in".
|
|
623
|
+
* @param {object} [params] Interpolation parameters, e.g. { name: 'Post' }.
|
|
624
|
+
* @returns {string}
|
|
625
|
+
*/
|
|
626
|
+
function t(key, params) {
|
|
627
|
+
const parts = key.split('.');
|
|
628
|
+
let val = i18n;
|
|
629
|
+
for (const p of parts) {
|
|
630
|
+
if (val && typeof val === 'object') val = val[p];
|
|
631
|
+
else { val = undefined; break; }
|
|
632
|
+
}
|
|
633
|
+
if (typeof val !== 'string') return key;
|
|
634
|
+
if (!params) return val;
|
|
635
|
+
return val.replace(/\{\{(\w+)\}\}/g, (_, k) => params[k] !== undefined ? String(params[k]) : `{{${k}}}`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Apply translations to all [data-i18n] and [data-i18n-ph] elements. */
|
|
639
|
+
function applyI18n() {
|
|
640
|
+
document.documentElement.lang = currentLang;
|
|
641
|
+
document.title = t('page.title');
|
|
642
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
643
|
+
el.textContent = t(el.dataset.i18n);
|
|
644
|
+
});
|
|
645
|
+
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
|
646
|
+
el.placeholder = t(el.dataset.i18nPh);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
651
|
+
let schema = null, currentItem = null, editingRecord = null, toastTimer;
|
|
652
|
+
let authToken = localStorage.getItem('cs_token') || null; // NOTE: httpOnly cookie preferred in production
|
|
653
|
+
let authUser = null, authCollection = null;
|
|
654
|
+
|
|
655
|
+
// Table state
|
|
656
|
+
let curPage = 1, curLastPage = 1, curPerPage = 20;
|
|
657
|
+
let curSearch = '', curOrderBy = 'createdAt', curOrder = 'DESC';
|
|
658
|
+
let advFilters = [];
|
|
659
|
+
let selectedIds = new Set();
|
|
660
|
+
let tableRows = [];
|
|
661
|
+
let hlRowIdx = -1;
|
|
662
|
+
let searchDebounce = null;
|
|
663
|
+
|
|
664
|
+
// Vim key chording
|
|
665
|
+
let lastVimKey = '', lastVimTime = 0;
|
|
666
|
+
|
|
667
|
+
// Realtime
|
|
668
|
+
let activityLog = [];
|
|
669
|
+
let rtWs = null;
|
|
670
|
+
|
|
671
|
+
// Devs modal
|
|
672
|
+
let devsTab = 'sdk';
|
|
673
|
+
|
|
674
|
+
// Config editor
|
|
675
|
+
let configMode = false;
|
|
676
|
+
let configData = null;
|
|
677
|
+
let configActiveTab = 'general';
|
|
678
|
+
let _cfgEntityId = 0, _cfgEndpointId = 0, _cfgFileId = 0, _cfgRlId = 0, _cfgTriggerId = 0;
|
|
679
|
+
|
|
680
|
+
// ── HTMX: inject Bearer token and language on every request ───────────
|
|
681
|
+
document.body.addEventListener('htmx:configRequest', e => {
|
|
682
|
+
if (authToken) e.detail.headers['Authorization'] = 'Bearer ' + authToken;
|
|
683
|
+
e.detail.headers['Accept-Language'] = currentLang;
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// ── HTMX: after table partial swaps in ─────────────────────────────────
|
|
687
|
+
document.body.addEventListener('htmx:afterSwap', e => {
|
|
688
|
+
if (e.detail.target.id !== 'table-area') return;
|
|
689
|
+
document.getElementById('loading').classList.add('hidden');
|
|
690
|
+
});
|
|
691
|
+
document.body.addEventListener('htmx:responseError', e => {
|
|
692
|
+
if (e.detail.target?.id === 'table-area') {
|
|
693
|
+
document.getElementById('loading').classList.add('hidden');
|
|
694
|
+
toast(t('toast.failed_load_data'), 'error');
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// ── Bootstrap ─────────────────────────────────────────────────────────
|
|
699
|
+
async function init() {
|
|
700
|
+
const preferredLang = ((navigator.language || 'en').split('-')[0] || 'en').toLowerCase();
|
|
701
|
+
await loadI18n(preferredLang);
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
schema = await fetch('/admin/schema').then(r => r.json());
|
|
705
|
+
} catch { toast(t('toast.failed_load_schema'), 'error'); return; }
|
|
706
|
+
|
|
707
|
+
document.getElementById('project-name').textContent = schema.name;
|
|
708
|
+
buildSidebar();
|
|
709
|
+
|
|
710
|
+
if (authToken) {
|
|
711
|
+
try {
|
|
712
|
+
const col = getAdminCollections()[0];
|
|
713
|
+
if (col) {
|
|
714
|
+
const r = await apiFetch(`/api/auth/${toSlug(col.name)}/me`);
|
|
715
|
+
if (r.ok) { authUser = await r.json(); authCollection = col.name; showApp(); return; }
|
|
716
|
+
}
|
|
717
|
+
} catch { /* fall through to login */ }
|
|
718
|
+
authToken = null;
|
|
719
|
+
localStorage.removeItem('cs_token');
|
|
720
|
+
}
|
|
721
|
+
window.location.replace('/login');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── Auth ───────────────────────────────────────────────────────────────
|
|
725
|
+
function getAdminCollections() {
|
|
726
|
+
return (schema.userCollections || schema.entities || []).filter(e => e.admin !== false);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function getNonAuthEntities() {
|
|
730
|
+
return (schema.entities || []).filter(function(e) { return !e.authenticable; });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ── Sidebar (mobile) ───────────────────────────────────────────────────
|
|
734
|
+
function openSidebar() {
|
|
735
|
+
document.getElementById('sidebar').classList.replace('-translate-x-full', 'translate-x-0');
|
|
736
|
+
document.getElementById('sidebar-overlay').classList.remove('hidden');
|
|
737
|
+
}
|
|
738
|
+
function closeSidebar() {
|
|
739
|
+
document.getElementById('sidebar').classList.replace('translate-x-0', '-translate-x-full');
|
|
740
|
+
document.getElementById('sidebar-overlay').classList.add('hidden');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ── Sidebar nav ────────────────────────────────────────────────────────
|
|
744
|
+
function buildSidebar() {
|
|
745
|
+
document.getElementById('nav-user-collections').innerHTML =
|
|
746
|
+
(schema.userCollections || []).map(uc =>
|
|
747
|
+
`<div class="nav-item" onclick="selectItem('collection','${escHtml(uc.name)}',this)">${escHtml(uc.name)}</div>`
|
|
748
|
+
).join('');
|
|
749
|
+
document.getElementById('nav-entities').innerHTML =
|
|
750
|
+
getNonAuthEntities().map(e =>
|
|
751
|
+
`<div class="nav-item" onclick="selectItem('entity','${escHtml(e.name)}',this)">${escHtml(e.name)}</div>`
|
|
752
|
+
).join('');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function selectItem(type, name, el) {
|
|
756
|
+
configMode = false;
|
|
757
|
+
document.getElementById('config-area').classList.add('hidden');
|
|
758
|
+
document.getElementById('api-keys-area').classList.add('hidden');
|
|
759
|
+
document.getElementById('table-area').classList.remove('hidden');
|
|
760
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
761
|
+
el.classList.add('active');
|
|
762
|
+
currentItem = { type, name };
|
|
763
|
+
document.getElementById('page-title').textContent = name;
|
|
764
|
+
document.getElementById('btn-add').classList.remove('hidden');
|
|
765
|
+
closeSidebar();
|
|
766
|
+
loadTable(type, name);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function logout() {
|
|
770
|
+
authToken = null; authUser = null; authCollection = null;
|
|
771
|
+
localStorage.removeItem('cs_token'); currentItem = null;
|
|
772
|
+
if (rtWs) { try { rtWs.close(); } catch {} rtWs = null; }
|
|
773
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
774
|
+
window.location.replace('/login');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function showLogin() {
|
|
778
|
+
window.location.replace('/login');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function showApp() {
|
|
782
|
+
document.getElementById('login-overlay') && document.getElementById('login-overlay').classList.add('hidden');
|
|
783
|
+
const name = authUser ? (authUser.name || authUser.email || '?') : '?';
|
|
784
|
+
document.getElementById('user-name').textContent = name;
|
|
785
|
+
document.getElementById('user-collection-label').textContent = authCollection || '';
|
|
786
|
+
document.getElementById('user-avatar').textContent = name[0].toUpperCase();
|
|
787
|
+
setupRealtime();
|
|
788
|
+
// Restore entity from URL if present
|
|
789
|
+
const qp = new URLSearchParams(location.search);
|
|
790
|
+
if (qp.get('entity')) {
|
|
791
|
+
const navEl = [...document.querySelectorAll('.nav-item')].find(el => el.textContent.trim() === qp.get('entity'));
|
|
792
|
+
if (navEl) { navEl.click(); return; }
|
|
793
|
+
}
|
|
794
|
+
selectDashboard(document.getElementById('nav-dashboard-item'));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── Realtime ──────────────────────────────────────────────────────────────────
|
|
798
|
+
function setupRealtime() {
|
|
799
|
+
if (rtWs) return;
|
|
800
|
+
try {
|
|
801
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
802
|
+
rtWs = new WebSocket(`${proto}//${location.host}/realtime`);
|
|
803
|
+
const badge = document.getElementById('ws-badge');
|
|
804
|
+
rtWs.onopen = () => {
|
|
805
|
+
rtWs.send(JSON.stringify({ type: 'subscribe', channel: '*' }));
|
|
806
|
+
if (badge) { badge.textContent = 'live'; badge.style.background = 'rgba(52,177,235,.15)'; badge.style.color = '#34b1eb'; }
|
|
807
|
+
};
|
|
808
|
+
rtWs.onmessage = e => {
|
|
809
|
+
try {
|
|
810
|
+
const msg = JSON.parse(e.data);
|
|
811
|
+
if (msg.type === 'event') onRealtimeEvent(msg);
|
|
812
|
+
} catch {}
|
|
813
|
+
};
|
|
814
|
+
rtWs.onclose = () => {
|
|
815
|
+
rtWs = null;
|
|
816
|
+
if (badge) { badge.textContent = 'offline'; badge.style.color = '#888'; }
|
|
817
|
+
};
|
|
818
|
+
} catch {}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function onRealtimeEvent(msg) {
|
|
822
|
+
const [entityName, action] = (msg.event || '').split('.');
|
|
823
|
+
const data = msg.data || {};
|
|
824
|
+
const label = data.name || data.title || data.email || `${entityName} #${String(data.id || '').slice(0, 8)}`;
|
|
825
|
+
activityLog.unshift({ entityName, action: action || 'event', label, id: data.id, at: new Date().toISOString() });
|
|
826
|
+
if (activityLog.length > 60) activityLog.pop();
|
|
827
|
+
renderActivityFeed();
|
|
828
|
+
// Refresh table if viewing this entity
|
|
829
|
+
if (currentItem && currentItem.name === entityName && !document.getElementById('table-view').classList.contains('hidden')) {
|
|
830
|
+
loadTableData();
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
|
835
|
+
function openSidebar() {
|
|
836
|
+
document.getElementById('sidebar').classList.replace('-translate-x-full', 'translate-x-0');
|
|
837
|
+
document.getElementById('sidebar-overlay').classList.remove('hidden');
|
|
838
|
+
}
|
|
839
|
+
function closeSidebar() {
|
|
840
|
+
document.getElementById('sidebar').classList.replace('translate-x-0', '-translate-x-full');
|
|
841
|
+
document.getElementById('sidebar-overlay').classList.add('hidden');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function buildSidebar() {
|
|
845
|
+
// Collections = authenticable entities (🆔 emoji)
|
|
846
|
+
document.getElementById('nav-user-collections').innerHTML =
|
|
847
|
+
(schema.entities || []).filter(e => e.authenticable).map(e =>
|
|
848
|
+
`<div class="nav-item" onclick="selectItem('collection','${escHtml(e.name)}',this)">🆔 ${escHtml(e.name)}</div>`
|
|
849
|
+
).join('');
|
|
850
|
+
// Entities = non-authenticable (📁 emoji)
|
|
851
|
+
document.getElementById('nav-entities').innerHTML =
|
|
852
|
+
(schema.entities || []).filter(e => !e.authenticable).map(e =>
|
|
853
|
+
`<div class="nav-item" onclick="selectItem('entity','${escHtml(e.name)}',this)">📁 ${escHtml(e.name)}</div>`
|
|
854
|
+
).join('');
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function setActiveNav(el) {
|
|
858
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
859
|
+
if (el) el.classList.add('active');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function showOnlyArea(id) {
|
|
863
|
+
['dashboard-area', 'table-view', 'config-area', 'api-keys-area'].forEach(a => {
|
|
864
|
+
document.getElementById(a).classList.toggle('hidden', a !== id);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function selectDashboard(el) {
|
|
869
|
+
configMode = false;
|
|
870
|
+
setActiveNav(el);
|
|
871
|
+
showOnlyArea('dashboard-area');
|
|
872
|
+
document.getElementById('btn-add').classList.add('hidden');
|
|
873
|
+
document.getElementById('btn-share').classList.add('hidden');
|
|
874
|
+
document.getElementById('btn-devs').classList.add('hidden');
|
|
875
|
+
document.getElementById('page-title').textContent = 'Dashboard';
|
|
876
|
+
currentItem = null;
|
|
877
|
+
closeSidebar();
|
|
878
|
+
loadDashboard();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function selectItem(type, name, el) {
|
|
882
|
+
configMode = false;
|
|
883
|
+
setActiveNav(el);
|
|
884
|
+
showOnlyArea('table-view');
|
|
885
|
+
currentItem = { type, name };
|
|
886
|
+
document.getElementById('page-title').textContent = name;
|
|
887
|
+
document.getElementById('btn-add').classList.remove('hidden');
|
|
888
|
+
document.getElementById('btn-share').classList.remove('hidden');
|
|
889
|
+
document.getElementById('btn-devs').classList.remove('hidden');
|
|
890
|
+
closeSidebar();
|
|
891
|
+
// Reset table state
|
|
892
|
+
curPage = 1; curSearch = ''; advFilters = [];
|
|
893
|
+
selectedIds.clear(); hlRowIdx = -1;
|
|
894
|
+
document.getElementById('search-input').value = '';
|
|
895
|
+
document.getElementById('order-by-sel').value = 'createdAt';
|
|
896
|
+
document.getElementById('order-dir-sel').value = 'DESC';
|
|
897
|
+
updateSortOptions(name);
|
|
898
|
+
loadTableData();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function selectConfig(el) {
|
|
902
|
+
configMode = true;
|
|
903
|
+
setActiveNav(el);
|
|
904
|
+
showOnlyArea('config-area');
|
|
905
|
+
document.getElementById('btn-add').classList.add('hidden');
|
|
906
|
+
document.getElementById('btn-share').classList.add('hidden');
|
|
907
|
+
document.getElementById('btn-devs').classList.add('hidden');
|
|
908
|
+
document.getElementById('page-title').textContent = 'Config Editor';
|
|
909
|
+
currentItem = null;
|
|
910
|
+
closeSidebar();
|
|
911
|
+
loadConfig();
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function updateSortOptions(entityName) {
|
|
915
|
+
const entity = (schema.entities || []).find(e => e.name === entityName);
|
|
916
|
+
const sel = document.getElementById('order-by-sel');
|
|
917
|
+
const cur = sel.value;
|
|
918
|
+
sel.innerHTML = '<option value="createdAt">Created</option><option value="updatedAt">Updated</option><option value="id">ID</option>';
|
|
919
|
+
if (entity && entity.properties) {
|
|
920
|
+
for (const p of entity.properties) {
|
|
921
|
+
const pName = typeof p === 'string' ? p : p.name;
|
|
922
|
+
if (!['createdAt', 'updatedAt', 'id'].includes(pName)) {
|
|
923
|
+
const opt = document.createElement('option');
|
|
924
|
+
opt.value = pName; opt.textContent = pName;
|
|
925
|
+
sel.appendChild(opt);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
sel.value = ['createdAt', 'updatedAt', 'id'].includes(cur) ? cur : 'createdAt';
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
933
|
+
async function loadDashboard() {
|
|
934
|
+
document.getElementById('stats-grid').innerHTML = '<div class="text-xs text-cs-muted">Loading stats…</div>';
|
|
935
|
+
try {
|
|
936
|
+
const r = await apiFetch('/admin/stats');
|
|
937
|
+
if (!r.ok) throw new Error('Stats fetch failed');
|
|
938
|
+
const data = await r.json();
|
|
939
|
+
renderStatsGrid(data.entities || []);
|
|
940
|
+
renderEntitySummary(data.entities || []);
|
|
941
|
+
// Seed activity log with server data (if not already live)
|
|
942
|
+
for (const item of (data.recentActivity || [])) {
|
|
943
|
+
if (!activityLog.find(a => a.id === item.id && a.entityName === item.entityName)) {
|
|
944
|
+
activityLog.push({ entityName: item.entityName, action: item.action, label: item.label, id: item.id, at: item.createdAt });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
activityLog.sort((a, b) => new Date(b.at) - new Date(a.at));
|
|
948
|
+
renderActivityFeed();
|
|
949
|
+
} catch {
|
|
950
|
+
document.getElementById('stats-grid').innerHTML = '<div class="text-xs text-red-400">Failed to load stats</div>';
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function renderStatsGrid(entities) {
|
|
955
|
+
const grid = document.getElementById('stats-grid');
|
|
956
|
+
if (!entities.length) { grid.innerHTML = '<div class="text-xs text-cs-muted">No entities found.</div>'; return; }
|
|
957
|
+
grid.innerHTML = entities.map(e => `
|
|
958
|
+
<div class="bg-cs-surface border border-cs-border rounded p-4 cursor-pointer hover:border-cs-primary" style="transition:border-color 150ms;"
|
|
959
|
+
onclick="document.querySelector('.nav-item:not(#nav-dashboard-item):not(#nav-config-item)')?.click()">
|
|
960
|
+
<div class="text-xs text-cs-muted mb-1">${escHtml(e.name)}</div>
|
|
961
|
+
<div class="text-2xl font-bold" style="color:#34b1eb;">${e.total}</div>
|
|
962
|
+
<div class="text-xs mt-1 space-x-2">
|
|
963
|
+
<span class="text-green-400">+${e.lastWeek} this week</span>
|
|
964
|
+
<span class="text-cs-muted">+${e.lastMonth} this month</span>
|
|
965
|
+
</div>
|
|
966
|
+
</div>`).join('');
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function renderEntitySummary(entities) {
|
|
970
|
+
const el = document.getElementById('entity-summary');
|
|
971
|
+
el.innerHTML = entities.map(e => `
|
|
972
|
+
<div class="flex items-center justify-between py-2 border-b border-cs-border last:border-0">
|
|
973
|
+
<span class="text-sm text-cs-text">${escHtml(e.name)}</span>
|
|
974
|
+
<div class="flex gap-3 text-xs">
|
|
975
|
+
<span class="text-cs-muted">${e.total} total</span>
|
|
976
|
+
<span class="text-green-400">+${e.lastWeek}/wk</span>
|
|
977
|
+
</div>
|
|
978
|
+
</div>`).join('') || '<div class="text-xs text-cs-muted">No entities.</div>';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function renderActivityFeed() {
|
|
982
|
+
const el = document.getElementById('activity-feed');
|
|
983
|
+
if (!activityLog.length) {
|
|
984
|
+
el.innerHTML = '<div class="text-xs text-cs-muted py-4">No activity yet. Live events will appear here.</div>';
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function deleteRecord(id) {
|
|
989
|
+
if (!currentItem || !confirm(t('table.delete_confirm', { id }))) return;
|
|
990
|
+
try {
|
|
991
|
+
const r = await apiFetch(`/api/${toKebab(currentItem.name)}s/${id}`, { method: 'DELETE' });
|
|
992
|
+
if (!r.ok) { const d = await r.json(); throw new Error(d.error || t('toast.record_deleted')); }
|
|
993
|
+
toast(t('toast.record_deleted'), 'success');
|
|
994
|
+
reloadTable();
|
|
995
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const dotClass = a => ({ created: 'dot-create', updated: 'dot-update', deleted: 'dot-delete' }[a] || 'dot-create');
|
|
999
|
+
el.innerHTML = activityLog.slice(0, 40).map(item => `
|
|
1000
|
+
<div class="flex items-start gap-2 py-1.5 border-b border-cs-border last:border-0">
|
|
1001
|
+
<div class="activity-dot ${dotClass(item.action)} mt-1"></div>
|
|
1002
|
+
<div class="flex-1 min-w-0">
|
|
1003
|
+
<span class="text-cs-text font-medium truncate block">${escHtml(item.label || item.entityName)}</span>
|
|
1004
|
+
<span class="text-cs-muted">${escHtml(item.entityName)} · ${escHtml(item.action)}</span>
|
|
1005
|
+
</div>
|
|
1006
|
+
<span class="text-cs-muted shrink-0 text-[10px]">${fmtTime(item.at)}</span>
|
|
1007
|
+
</div>`).join('');
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function fmtTime(iso) {
|
|
1011
|
+
try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ── Table ─────────────────────────────────────────────────────────────────────
|
|
1015
|
+
async function loadTableData() {
|
|
1016
|
+
if (!currentItem) return;
|
|
1017
|
+
document.getElementById('table-area').innerHTML = '';
|
|
1018
|
+
document.getElementById('pagination').classList.add('hidden');
|
|
1019
|
+
document.getElementById('loading').classList.remove('hidden');
|
|
1020
|
+
|
|
1021
|
+
const params = new URLSearchParams({
|
|
1022
|
+
type: currentItem.type, name: currentItem.name,
|
|
1023
|
+
page: curPage, perPage: curPerPage,
|
|
1024
|
+
orderBy: curOrderBy, order: curOrder,
|
|
1025
|
+
});
|
|
1026
|
+
if (curSearch) params.set('search', curSearch);
|
|
1027
|
+
for (const f of advFilters) {
|
|
1028
|
+
if (f.col && f.op !== undefined && f.val !== '') params.set(`${f.col}${f.op}`, f.val);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
try {
|
|
1032
|
+
const r = await apiFetch('/admin/data?' + params.toString());
|
|
1033
|
+
const result = await r.json();
|
|
1034
|
+
document.getElementById('loading').classList.add('hidden');
|
|
1035
|
+
if (!r.ok) { document.getElementById('table-area').innerHTML = `<div class="text-red-400 text-sm p-4">${escHtml(result.error || 'Error')}</div>`; return; }
|
|
1036
|
+
curLastPage = result.lastPage || 1;
|
|
1037
|
+
curPage = result.currentPage || 1;
|
|
1038
|
+
tableRows = result.data || [];
|
|
1039
|
+
renderTable(tableRows);
|
|
1040
|
+
renderPagination(result);
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
document.getElementById('loading').classList.add('hidden');
|
|
1043
|
+
document.getElementById('table-area').innerHTML = `<div class="text-red-400 text-sm p-4">${escHtml(err.message)}</div>`;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function renderTable(rows) {
|
|
1048
|
+
selectedIds.clear(); updateBulkBar();
|
|
1049
|
+
if (!rows.length) {
|
|
1050
|
+
document.getElementById('table-area').innerHTML = `
|
|
1051
|
+
<div class="flex flex-col items-center justify-center py-20 text-center">
|
|
1052
|
+
<div class="text-4xl mb-3" aria-hidden="true">📭</div>
|
|
1053
|
+
<p class="text-sm" style="color:var(--color-cs-muted);">No records found. Click <span style="color:var(--color-cs-text);">+ New record</span> to create one.</p>
|
|
1054
|
+
</div>`;
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const allCols = Object.keys(rows[0]);
|
|
1059
|
+
const hiddenCols = new Set(currentItem ? getHiddenColumns(currentItem.name) : []);
|
|
1060
|
+
const cols = allCols.filter(c => !hiddenCols.has(c));
|
|
1061
|
+
|
|
1062
|
+
const selectAllTh = `<th class="px-3 py-2.5 w-8"><input type="checkbox" id="select-all" onchange="toggleSelectAll()" style="accent-color:${currentPrimaryColor};cursor:pointer;" /></th>`;
|
|
1063
|
+
const actionsTh = `<th class="px-3 py-2.5 text-left text-xs font-medium whitespace-nowrap" style="color:var(--color-cs-muted);">Actions</th>`;
|
|
1064
|
+
const dataThs = cols.map(c => `<th class="px-3 py-2.5 text-left text-xs font-medium whitespace-nowrap" style="color:var(--color-cs-muted);">${escHtml(c)}</th>`).join('');
|
|
1065
|
+
|
|
1066
|
+
const trs = rows.map((row, idx) => {
|
|
1067
|
+
const safeJson = JSON.stringify(row).replace(/&/g,'\\u0026').replace(/'/g,'\\u0027').replace(/</g,'\\u003c').replace(/>/g,'\\u003e');
|
|
1068
|
+
const rowId = String(row.id);
|
|
1069
|
+
const actions = `<td class="px-3 py-2.5"><div class="flex gap-1">
|
|
1070
|
+
<button class="text-xs border rounded px-2 py-0.5 cursor-pointer hover:opacity-80" style="border-color:var(--color-cs-border);color:var(--color-cs-muted);" onclick='viewRecord(${safeJson})'>View</button>
|
|
1071
|
+
<button class="text-xs border rounded px-2 py-0.5 cursor-pointer hover:opacity-80" style="border-color:var(--color-cs-border);color:var(--color-cs-text);" onclick='openEditModal(${safeJson})'>Edit</button>
|
|
1072
|
+
<button class="text-xs border rounded px-2 py-0.5 cursor-pointer hover:opacity-80" style="border-color:rgba(239,68,68,.4);color:#f87171;" onclick="deleteRecord('${rowId}')">Delete</button>
|
|
1073
|
+
</div></td>`;
|
|
1074
|
+
const tds = cols.map(c => `<td class="px-3 py-2.5 max-w-xs truncate text-sm" style="color:var(--color-cs-text);" title="${escAttr(String(row[c] ?? ''))}">${escHtml(String(row[c] ?? ''))}</td>`).join('');
|
|
1075
|
+
return `<tr class="border-b" style="border-color:var(--color-cs-border);" data-idx="${idx}" data-id="${escAttr(rowId)}" onclick="handleRowClick(event,'${rowId}',${idx})">
|
|
1076
|
+
<td class="px-3 py-2.5 w-8"><input type="checkbox" class="row-cb cursor-pointer" value="${escAttr(rowId)}" onchange="toggleRowSel('${rowId}')" onclick="event.stopPropagation()" style="accent-color:${currentPrimaryColor};" ${selectedIds.has(rowId) ? 'checked' : ''} /></td>
|
|
1077
|
+
${actions}${tds}
|
|
1078
|
+
</tr>`;
|
|
1079
|
+
}).join('');
|
|
1080
|
+
|
|
1081
|
+
document.getElementById('table-area').innerHTML = `
|
|
1082
|
+
<div class="overflow-x-auto border rounded" style="border-color:var(--color-cs-border);">
|
|
1083
|
+
<table class="w-full text-sm" style="color:var(--color-cs-text);">
|
|
1084
|
+
<thead style="background:var(--color-cs-surface);"><tr class="border-b" style="border-color:var(--color-cs-border);">${selectAllTh}${actionsTh}${dataThs}</tr></thead>
|
|
1085
|
+
<tbody>${trs}</tbody>
|
|
1086
|
+
</table>
|
|
1087
|
+
</div>`;
|
|
1088
|
+
if (hlRowIdx >= 0 && hlRowIdx < rows.length) highlightRow(hlRowIdx);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function handleRowClick(event, id, idx) {
|
|
1092
|
+
if (event.target.type === 'checkbox') return;
|
|
1093
|
+
hlRowIdx = idx;
|
|
1094
|
+
highlightRow(idx);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function highlightRow(idx) {
|
|
1098
|
+
document.querySelectorAll('#table-area tbody tr').forEach((r, i) => r.classList.toggle('row-hl', i === idx));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function renderPagination(result) {
|
|
1102
|
+
const pg = document.getElementById('pagination');
|
|
1103
|
+
pg.classList.remove('hidden');
|
|
1104
|
+
document.getElementById('pg-info').textContent = `${result.from || 0}–${result.to || 0} of ${result.total || 0}`;
|
|
1105
|
+
const btns = document.getElementById('pg-btns');
|
|
1106
|
+
btns.innerHTML = '';
|
|
1107
|
+
const s = Math.max(1, curPage - 2), e = Math.min(curLastPage, curPage + 2);
|
|
1108
|
+
for (let i = s; i <= e; i++) {
|
|
1109
|
+
const b = document.createElement('button');
|
|
1110
|
+
b.textContent = i; b.onclick = () => goPage(i);
|
|
1111
|
+
b.className = 'px-2 py-1 text-xs border rounded cursor-pointer';
|
|
1112
|
+
b.style.cssText = i === curPage ? 'border-color:#34b1eb;color:#34b1eb;background:rgba(52,177,235,.08);' : 'border-color:var(--color-cs-border);color:var(--color-cs-muted);';
|
|
1113
|
+
btns.appendChild(b);
|
|
1114
|
+
}
|
|
1115
|
+
document.getElementById('pg-first').disabled = curPage <= 1;
|
|
1116
|
+
document.getElementById('pg-prev').disabled = curPage <= 1;
|
|
1117
|
+
document.getElementById('pg-next').disabled = curPage >= curLastPage;
|
|
1118
|
+
document.getElementById('pg-last').disabled = curPage >= curLastPage;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function goPage(n) {
|
|
1122
|
+
n = Math.max(1, Math.min(curLastPage, parseInt(n, 10) || 1));
|
|
1123
|
+
if (n === curPage) return;
|
|
1124
|
+
curPage = n; loadTableData();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function onSearchInput() {
|
|
1128
|
+
clearTimeout(searchDebounce);
|
|
1129
|
+
searchDebounce = setTimeout(() => {
|
|
1130
|
+
curSearch = document.getElementById('search-input').value;
|
|
1131
|
+
curPage = 1; loadTableData();
|
|
1132
|
+
}, 300);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function onOrderChange() {
|
|
1136
|
+
curOrderBy = document.getElementById('order-by-sel').value;
|
|
1137
|
+
curOrder = document.getElementById('order-dir-sel').value;
|
|
1138
|
+
curPage = 1; loadTableData();
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function onPerPageChange() {
|
|
1142
|
+
curPerPage = parseInt(document.getElementById('per-page-sel').value, 10);
|
|
1143
|
+
curPage = 1; loadTableData();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function reloadTable() { if (currentItem) loadTableData(); }
|
|
1147
|
+
|
|
1148
|
+
// ── Advanced filters ──────────────────────────────────────────────────────────
|
|
1149
|
+
function toggleAdvancedSearch() {
|
|
1150
|
+
const p = document.getElementById('adv-panel');
|
|
1151
|
+
const hidden = p.classList.contains('hidden');
|
|
1152
|
+
p.classList.toggle('hidden', !hidden);
|
|
1153
|
+
if (hidden && !document.getElementById('adv-filter-list').children.length) addAdvFilter();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function getEntityCols() {
|
|
1157
|
+
if (!currentItem) return [];
|
|
1158
|
+
const e = (schema.entities || []).find(e => e.name === currentItem.name);
|
|
1159
|
+
const cols = ['id', 'createdAt', 'updatedAt'];
|
|
1160
|
+
if (e) for (const p of e.properties || []) cols.push(typeof p === 'string' ? p : p.name);
|
|
1161
|
+
return cols;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function addAdvFilter() {
|
|
1165
|
+
const cols = getEntityCols();
|
|
1166
|
+
const ops = [['', '='], ['_eq', '='], ['_neq', '≠'], ['_gt', '>'], ['_gte', '≥'], ['_lt', '<'], ['_lte', '≤'], ['_like', 'like'], ['_in', 'in (a,b,c)']];
|
|
1167
|
+
const colOpts = cols.map(c => `<option value="${escAttr(c)}">${escHtml(c)}</option>`).join('');
|
|
1168
|
+
const opOpts = ops.map(([v, l]) => `<option value="${escAttr(v)}">${escHtml(l)}</option>`).join('');
|
|
1169
|
+
const div = document.createElement('div');
|
|
1170
|
+
div.className = 'flex gap-2 items-center';
|
|
1171
|
+
div.innerHTML = `
|
|
1172
|
+
<select data-role="col" class="bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1 text-xs outline-none flex-1">${colOpts}</select>
|
|
1173
|
+
<select data-role="op" class="bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1 text-xs outline-none">${opOpts}</select>
|
|
1174
|
+
<input type="text" data-role="val" placeholder="value" class="bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1 text-xs outline-none flex-1" />
|
|
1175
|
+
<button onclick="this.parentElement.remove()" class="text-cs-muted hover:text-red-400 text-xs cursor-pointer">✕</button>`;
|
|
1176
|
+
document.getElementById('adv-filter-list').appendChild(div);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function applyAdvFilters() {
|
|
1180
|
+
advFilters = [];
|
|
1181
|
+
for (const row of document.getElementById('adv-filter-list').children) {
|
|
1182
|
+
const col = row.querySelector('[data-role=col]').value;
|
|
1183
|
+
const op = row.querySelector('[data-role=op]').value;
|
|
1184
|
+
const val = row.querySelector('[data-role=val]').value;
|
|
1185
|
+
if (col && val !== '') advFilters.push({ col, op, val });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Entities tab: card list with name, flags, relations, property rows
|
|
1189
|
+
function renderEntitiesForm() {
|
|
1190
|
+
_cfgEntityId = 0;
|
|
1191
|
+
const entities = (configData && configData.entities) || {};
|
|
1192
|
+
let cards = '';
|
|
1193
|
+
for (const key of Object.keys(entities)) cards += renderEntityCard(key, entities[key]);
|
|
1194
|
+
return '<div id="cfg-entities-list" class="space-y-3">' + cards + '</div>'
|
|
1195
|
+
+ '<button type="button" onclick="addEntityCard()"'
|
|
1196
|
+
+ ' class="mt-3 text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer"'
|
|
1197
|
+
+ ' style="transition:color 150ms,background 150ms;">' + t('config.entities.add_entity') + '</button>';
|
|
1198
|
+
}
|
|
1199
|
+
curPage = 1; loadTableData();
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function clearAdvFilters() {
|
|
1203
|
+
advFilters = [];
|
|
1204
|
+
document.getElementById('adv-filter-list').innerHTML = '';
|
|
1205
|
+
curPage = 1; loadTableData();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ── Bulk actions ──────────────────────────────────────────────────────────────
|
|
1209
|
+
function toggleRowSel(id) {
|
|
1210
|
+
if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
|
|
1211
|
+
updateBulkBar();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function toggleSelectAll() {
|
|
1215
|
+
const cb = document.getElementById('select-all');
|
|
1216
|
+
if (cb && cb.checked) tableRows.forEach(r => selectedIds.add(String(r.id)));
|
|
1217
|
+
else selectedIds.clear();
|
|
1218
|
+
document.querySelectorAll('.row-cb').forEach(c => { c.checked = selectedIds.has(c.value); });
|
|
1219
|
+
updateBulkBar();
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function clearSelection() {
|
|
1223
|
+
selectedIds.clear();
|
|
1224
|
+
document.querySelectorAll('.row-cb').forEach(c => c.checked = false);
|
|
1225
|
+
const sa = document.getElementById('select-all'); if (sa) sa.checked = false;
|
|
1226
|
+
updateBulkBar();
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function updateBulkBar() {
|
|
1230
|
+
const bar = document.getElementById('bulk-bar');
|
|
1231
|
+
const cnt = selectedIds.size;
|
|
1232
|
+
if (cnt > 0) { bar.classList.remove('hidden'); document.getElementById('bulk-count').textContent = `${cnt} selected`; }
|
|
1233
|
+
else bar.classList.add('hidden');
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
async function bulkDelete() {
|
|
1237
|
+
if (!currentItem || selectedIds.size === 0) return;
|
|
1238
|
+
if (!confirm(`Delete ${selectedIds.size} record(s)?`)) return;
|
|
1239
|
+
const ids = [...selectedIds];
|
|
1240
|
+
let failed = 0;
|
|
1241
|
+
for (const id of ids) {
|
|
1242
|
+
try {
|
|
1243
|
+
const r = await apiFetch(getApiPath(id), { method: 'DELETE' });
|
|
1244
|
+
if (!r.ok) failed++;
|
|
1245
|
+
} catch { failed++; }
|
|
1246
|
+
}
|
|
1247
|
+
toast(failed ? `Deleted ${ids.length - failed}, failed ${failed}` : `Deleted ${ids.length} records`, failed ? 'error' : 'success');
|
|
1248
|
+
selectedIds.clear(); loadTableData();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function getApiPath(id) {
|
|
1252
|
+
if (!currentItem) return '';
|
|
1253
|
+
return `/api/collections/${toSlug(currentItem.name)}${id ? '/' + id : ''}`;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ── Export ────────────────────────────────────────────────────────────────────
|
|
1257
|
+
async function exportData(format) {
|
|
1258
|
+
if (!currentItem) return;
|
|
1259
|
+
let rows;
|
|
1260
|
+
// If there is an active selection, export only selected records from the current page
|
|
1261
|
+
if (selectedIds.size > 0) {
|
|
1262
|
+
rows = tableRows.filter(r => selectedIds.has(String(r.id)));
|
|
1263
|
+
} else {
|
|
1264
|
+
const params = new URLSearchParams({ type: currentItem.type, name: currentItem.name, page: 1, perPage: 10000, orderBy: curOrderBy, order: curOrder });
|
|
1265
|
+
if (curSearch) params.set('search', curSearch);
|
|
1266
|
+
for (const f of advFilters) if (f.col && f.val !== '') params.set(`${f.col}${f.op}`, f.val);
|
|
1267
|
+
try {
|
|
1268
|
+
const r = await apiFetch('/admin/data?' + params.toString());
|
|
1269
|
+
const result = await r.json();
|
|
1270
|
+
rows = result.data || [];
|
|
1271
|
+
} catch (e) { toast('Export failed: ' + e.message, 'error'); return; }
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
let content, mime, ext;
|
|
1275
|
+
if (format === 'json') {
|
|
1276
|
+
content = JSON.stringify(rows, null, 2); mime = 'application/json'; ext = 'json';
|
|
1277
|
+
} else {
|
|
1278
|
+
const cols = rows.length ? Object.keys(rows[0]) : [];
|
|
1279
|
+
const csvRow = arr => arr.map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',');
|
|
1280
|
+
content = [csvRow(cols), ...rows.map(row => csvRow(cols.map(c => row[c] ?? '')))].join('\n');
|
|
1281
|
+
mime = 'text/csv'; ext = 'csv';
|
|
1282
|
+
}
|
|
1283
|
+
const a = document.createElement('a');
|
|
1284
|
+
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
1285
|
+
a.download = `${currentItem.name}_export.${ext}`;
|
|
1286
|
+
a.click();
|
|
1287
|
+
} catch (e) { toast('Export failed: ' + e.message, 'error'); }
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ── Share View ────────────────────────────────────────────────────────────────
|
|
1291
|
+
function shareView() {
|
|
1292
|
+
if (!currentItem) return;
|
|
1293
|
+
const url = new URL(location.href);
|
|
1294
|
+
url.search = '';
|
|
1295
|
+
url.searchParams.set('entity', currentItem.name);
|
|
1296
|
+
url.searchParams.set('type', currentItem.type);
|
|
1297
|
+
if (curSearch) url.searchParams.set('search', curSearch);
|
|
1298
|
+
url.searchParams.set('orderBy', curOrderBy);
|
|
1299
|
+
url.searchParams.set('order', curOrder);
|
|
1300
|
+
url.searchParams.set('page', String(curPage));
|
|
1301
|
+
navigator.clipboard.writeText(url.toString())
|
|
1302
|
+
.then(() => toast('Link copied!', 'success'))
|
|
1303
|
+
.catch(() => toast('Copy failed', 'error'));
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// ── Developers modal ──────────────────────────────────────────────────────────
|
|
1307
|
+
function openDevsModal() {
|
|
1308
|
+
document.getElementById('devs-modal').classList.remove('hidden');
|
|
1309
|
+
devsTab = 'sdk';
|
|
1310
|
+
document.getElementById('devs-sdk-tab').classList.add('active');
|
|
1311
|
+
document.getElementById('devs-rest-tab').classList.remove('active');
|
|
1312
|
+
renderDevsBody();
|
|
1313
|
+
}
|
|
1314
|
+
function closeDevsModal() { document.getElementById('devs-modal').classList.add('hidden'); }
|
|
1315
|
+
function switchDevsTab(t) {
|
|
1316
|
+
devsTab = t;
|
|
1317
|
+
document.getElementById('devs-sdk-tab').classList.toggle('active', t === 'sdk');
|
|
1318
|
+
document.getElementById('devs-rest-tab').classList.toggle('active', t === 'rest');
|
|
1319
|
+
renderDevsBody();
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function buildQueryStr() {
|
|
1323
|
+
const parts = [];
|
|
1324
|
+
if (curSearch) parts.push(`search=${encodeURIComponent(curSearch)}`);
|
|
1325
|
+
parts.push(`page=${curPage}`, `perPage=${curPerPage}`, `orderBy=${curOrderBy}`, `order=${curOrder}`);
|
|
1326
|
+
for (const f of advFilters) if (f.col && f.val !== '') parts.push(`${f.col}${f.op}=${encodeURIComponent(f.val)}`);
|
|
1327
|
+
return parts.join('&');
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function renderDevsBody() {
|
|
1331
|
+
if (!currentItem) { document.getElementById('devs-body').innerHTML = '<p class="text-cs-muted text-sm">Select an entity first.</p>'; return; }
|
|
1332
|
+
const slug = toSlug(currentItem.name) + 's';
|
|
1333
|
+
const base = location.origin;
|
|
1334
|
+
const qs = buildQueryStr();
|
|
1335
|
+
const qsDisplay = qs ? `?${qs}` : '';
|
|
1336
|
+
const authNote = `// Set your auth token\nconst TOKEN = 'your_jwt_token_here';`;
|
|
1337
|
+
|
|
1338
|
+
const sdkCode = `import ChadStart from '@chadstart/sdk';
|
|
1339
|
+
const cs = new ChadStart('${base}');
|
|
1340
|
+
// cs.setToken(TOKEN); // if auth required
|
|
1341
|
+
|
|
1342
|
+
// List records${qs ? ' (current filters applied)' : ''}
|
|
1343
|
+
const list = await cs.from('${slug}').find(${qs ? `{\n params: { ${qs.replace(/&/g, ', ').replace(/=/g, ': ')} }\n}` : ''});
|
|
1344
|
+
|
|
1345
|
+
// Get one by ID
|
|
1346
|
+
const item = await cs.from('${slug}').findOne('RECORD_ID');
|
|
1347
|
+
|
|
1348
|
+
// Create new
|
|
1349
|
+
const created = await cs.from('${slug}').create({ /* fields */ });
|
|
1350
|
+
|
|
1351
|
+
// Update
|
|
1352
|
+
const updated = await cs.from('${slug}').update('RECORD_ID', { /* fields */ });
|
|
1353
|
+
|
|
1354
|
+
// Delete
|
|
1355
|
+
await cs.from('${slug}').delete('RECORD_ID');`;
|
|
1356
|
+
|
|
1357
|
+
const curlCode = `# List records${qs ? ' (current filters)' : ''}
|
|
1358
|
+
curl "${base}/api/collections/${toSlug(currentItem.name)}${qsDisplay}" \\
|
|
1359
|
+
-H "Authorization: Bearer $TOKEN"
|
|
1360
|
+
|
|
1361
|
+
# Get by ID
|
|
1362
|
+
curl "${base}/api/collections/${toSlug(currentItem.name)}/RECORD_ID" \\
|
|
1363
|
+
-H "Authorization: Bearer $TOKEN"
|
|
1364
|
+
|
|
1365
|
+
# Create
|
|
1366
|
+
curl -X POST "${base}/api/collections/${toSlug(currentItem.name)}" \\
|
|
1367
|
+
-H "Authorization: Bearer $TOKEN" \\
|
|
1368
|
+
-H "Content-Type: application/json" \\
|
|
1369
|
+
-d '{"field": "value"}'
|
|
1370
|
+
|
|
1371
|
+
# Update
|
|
1372
|
+
curl -X PATCH "${base}/api/collections/${toSlug(currentItem.name)}/RECORD_ID" \\
|
|
1373
|
+
-H "Authorization: Bearer $TOKEN" \\
|
|
1374
|
+
-H "Content-Type: application/json" \\
|
|
1375
|
+
-d '{"field": "new_value"}'
|
|
1376
|
+
|
|
1377
|
+
# Delete
|
|
1378
|
+
curl -X DELETE "${base}/api/collections/${toSlug(currentItem.name)}/RECORD_ID" \\
|
|
1379
|
+
-H "Authorization: Bearer $TOKEN"`;
|
|
1380
|
+
|
|
1381
|
+
const code = devsTab === 'sdk' ? sdkCode : curlCode;
|
|
1382
|
+
document.getElementById('devs-body').innerHTML = `
|
|
1383
|
+
${qs ? '<div class="text-xs mb-2" style="color:#34b1eb;">⚡ Current filters applied in examples</div>' : ''}
|
|
1384
|
+
<div class="code-block">${escHtml(code)}</div>
|
|
1385
|
+
<button onclick="copyDevCode()" class="mt-2 text-xs border border-cs-border rounded px-2 py-1 text-cs-muted hover:text-cs-text cursor-pointer">Copy</button>`;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function copyDevCode() {
|
|
1389
|
+
const el = document.querySelector('#devs-body .code-block');
|
|
1390
|
+
if (el) navigator.clipboard.writeText(el.textContent).then(() => toast('Copied!', 'success'));
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// ── Seed modal ────────────────────────────────────────────────────────────────
|
|
1394
|
+
function openSeedModal() {
|
|
1395
|
+
document.getElementById('seed-modal').classList.remove('hidden');
|
|
1396
|
+
document.getElementById('seed-result').classList.add('hidden');
|
|
1397
|
+
const entities = (schema.entities || []).filter(e => !e.single);
|
|
1398
|
+
document.getElementById('seed-list').innerHTML = entities.map(e => `
|
|
1399
|
+
<div class="flex items-center gap-3 py-2 border-b border-cs-border last:border-0">
|
|
1400
|
+
<input type="checkbox" id="seed-cb-${escAttr(e.name)}" value="${escAttr(e.name)}" checked style="accent-color:#34b1eb;cursor:pointer;" />
|
|
1401
|
+
<label for="seed-cb-${escAttr(e.name)}" class="text-sm text-cs-text flex-1 cursor-pointer">${escHtml(e.name)}</label>
|
|
1402
|
+
<input type="number" id="seed-cnt-${escAttr(e.name)}" value="10" min="1" max="500"
|
|
1403
|
+
class="w-20 bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1 text-sm outline-none focus:border-cs-primary text-right" />
|
|
1404
|
+
</div>`).join('') || '<p class="text-xs text-cs-muted">No entities found.</p>';
|
|
1405
|
+
}
|
|
1406
|
+
function closeSeedModal() { document.getElementById('seed-modal').classList.add('hidden'); }
|
|
1407
|
+
|
|
1408
|
+
async function doSeed() {
|
|
1409
|
+
const entities = (schema.entities || []).filter(e => !e.single);
|
|
1410
|
+
const toSeed = [];
|
|
1411
|
+
for (const e of entities) {
|
|
1412
|
+
const cb = document.getElementById(`seed-cb-${e.name}`);
|
|
1413
|
+
const cnt = document.getElementById(`seed-cnt-${e.name}`);
|
|
1414
|
+
if (cb && cb.checked) toSeed.push({ name: e.name, tableName: e.tableName, count: parseInt(cnt?.value || '10', 10) });
|
|
1415
|
+
}
|
|
1416
|
+
if (!toSeed.length) { toast('Select at least one entity', 'error'); return; }
|
|
1417
|
+
const btn = document.getElementById('seed-btn');
|
|
1418
|
+
btn.disabled = true; btn.textContent = 'Seeding…';
|
|
1419
|
+
try {
|
|
1420
|
+
const r = await apiFetch('/admin/seed', { method: 'POST', body: JSON.stringify({ entities: toSeed }), headers: { 'Content-Type': 'application/json' } });
|
|
1421
|
+
const data = await r.json();
|
|
1422
|
+
if (!r.ok) throw new Error(data.error || 'Seed failed');
|
|
1423
|
+
const msg = (data.results || []).map(r => `${r.name}: +${r.created}`).join(', ');
|
|
1424
|
+
const res = document.getElementById('seed-result');
|
|
1425
|
+
res.textContent = '✓ Seeded: ' + msg; res.classList.remove('hidden');
|
|
1426
|
+
toast('Database seeded!', 'success');
|
|
1427
|
+
if (currentItem) reloadTable();
|
|
1428
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1429
|
+
btn.disabled = false; btn.textContent = 'Seed';
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// ── Shortcuts modal ───────────────────────────────────────────────────────────
|
|
1433
|
+
function openShortcutsModal() { document.getElementById('shortcuts-modal').classList.remove('hidden'); }
|
|
1434
|
+
function closeShortcutsModal() { document.getElementById('shortcuts-modal').classList.add('hidden'); }
|
|
1435
|
+
|
|
1436
|
+
// ── Vim keyboard shortcuts ────────────────────────────────────────────────────
|
|
1437
|
+
document.addEventListener('keydown', e => {
|
|
1438
|
+
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName);
|
|
1439
|
+
const anyModal = ['modal-backdrop','devs-modal','seed-modal','shortcuts-modal'].some(id => !document.getElementById(id).classList.contains('hidden'));
|
|
1440
|
+
const loginOpen = !document.getElementById('login-overlay').classList.contains('hidden');
|
|
1441
|
+
|
|
1442
|
+
if (e.key === 'Escape') {
|
|
1443
|
+
if (!document.getElementById('shortcuts-modal').classList.contains('hidden')) { closeShortcutsModal(); return; }
|
|
1444
|
+
if (!document.getElementById('devs-modal').classList.contains('hidden')) { closeDevsModal(); return; }
|
|
1445
|
+
if (!document.getElementById('seed-modal').classList.contains('hidden')) { closeSeedModal(); return; }
|
|
1446
|
+
if (!document.getElementById('modal-backdrop').classList.contains('hidden')) { closeModal(); return; }
|
|
1447
|
+
if (inInput) { document.activeElement.blur(); return; }
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function renderPropRow(name, type) {
|
|
1451
|
+
return '<div class="flex gap-2 items-center prop-row">'
|
|
1452
|
+
+ '<input type="text" value="' + escAttr(name) + '" placeholder="' + escAttr(t('config.entities.property_placeholder')) + '"'
|
|
1453
|
+
+ ' class="flex-1 ' + _INP_XS + ' prop-name" style="transition:border-color 150ms;" />'
|
|
1454
|
+
+ _propTypeSelect(_INP_XS + ' prop-type', type)
|
|
1455
|
+
+ '<button type="button" onclick="_cfgRmPropRow(this)"'
|
|
1456
|
+
+ ' class="text-xs px-1.5 rounded cursor-pointer" style="color:var(--color-cs-muted);background:transparent;" title="' + escAttr(t('config.entities.remove')) + '">\xd7</button>'
|
|
1457
|
+
+ '</div>';
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function addPropRow(entityId) {
|
|
1461
|
+
document.getElementById('cfg-e-props-' + entityId).insertAdjacentHTML('beforeend', renderPropRow('', 'string'));
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (loginOpen || anyModal || inInput) return;
|
|
1465
|
+
|
|
1466
|
+
const now = Date.now();
|
|
1467
|
+
if (e.key === 'g' && lastVimKey === 'g' && now - lastVimTime < 600) { goPage(1); lastVimKey = ''; return; }
|
|
1468
|
+
lastVimKey = e.key; lastVimTime = now;
|
|
1469
|
+
|
|
1470
|
+
const rows = document.querySelectorAll('#table-area tbody tr');
|
|
1471
|
+
switch (e.key) {
|
|
1472
|
+
case '?': openShortcutsModal(); break;
|
|
1473
|
+
case '/': e.preventDefault(); document.getElementById('search-input')?.focus(); break;
|
|
1474
|
+
case 'n': if (currentItem) openAddModal(); break;
|
|
1475
|
+
case 'G': goPage(curLastPage); break;
|
|
1476
|
+
case 'j':
|
|
1477
|
+
if (!rows.length) break;
|
|
1478
|
+
hlRowIdx = Math.min(rows.length - 1, hlRowIdx + 1);
|
|
1479
|
+
highlightRow(hlRowIdx); rows[hlRowIdx]?.scrollIntoView({ block: 'nearest' }); break;
|
|
1480
|
+
case 'k':
|
|
1481
|
+
if (!rows.length) break;
|
|
1482
|
+
hlRowIdx = Math.max(0, hlRowIdx - 1);
|
|
1483
|
+
highlightRow(hlRowIdx); rows[hlRowIdx]?.scrollIntoView({ block: 'nearest' }); break;
|
|
1484
|
+
case 'e':
|
|
1485
|
+
if (hlRowIdx >= 0 && tableRows[hlRowIdx]) openEditModal(tableRows[hlRowIdx]); break;
|
|
1486
|
+
case 'd':
|
|
1487
|
+
if (hlRowIdx >= 0 && tableRows[hlRowIdx]) deleteRecord(String(tableRows[hlRowIdx].id)); break;
|
|
1488
|
+
case ']': goPage(curPage + 1); break;
|
|
1489
|
+
case '[': goPage(curPage - 1); break;
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
// ── Record modal ──────────────────────────────────────────────────────────────
|
|
1494
|
+
function getCurrentSchema() {
|
|
1495
|
+
if (!currentItem) return null;
|
|
1496
|
+
return (schema.entities || []).find(e => e.name === currentItem.name) || null;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function getFields(isCollection) {
|
|
1500
|
+
const s = getCurrentSchema();
|
|
1501
|
+
if (!s) return [];
|
|
1502
|
+
// For authenticable collections, email and password are always included once.
|
|
1503
|
+
// Filter them out from s.properties to avoid duplication.
|
|
1504
|
+
const RESERVED = new Set(isCollection ? ['email', 'password'] : []);
|
|
1505
|
+
const props = (s.properties || []).filter(p => {
|
|
1506
|
+
const pName = typeof p === 'string' ? p : p.name;
|
|
1507
|
+
return !RESERVED.has(pName);
|
|
1508
|
+
});
|
|
1509
|
+
const fields = isCollection
|
|
1510
|
+
? [{ name: 'email', type: 'email' }, { name: 'password', type: 'password' }, ...props]
|
|
1511
|
+
: [...props];
|
|
1512
|
+
if (!isCollection && s.belongsTo) {
|
|
1513
|
+
for (const rel of s.belongsTo) {
|
|
1514
|
+
const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name || String(rel));
|
|
1515
|
+
const relEntity = (schema.entities || []).find(e => e.name === relName);
|
|
1516
|
+
fields.push({ name: `${toSnake(relName)}_id`, type: 'id', relEntity });
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return fields;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function fieldHtml(f, value = '', forcePassword = false) {
|
|
1523
|
+
const fType = f.type || 'string';
|
|
1524
|
+
let inputHtml;
|
|
1525
|
+
const baseCls = 'w-full bg-cs-bg border border-cs-border rounded text-cs-text px-3 py-2 text-sm outline-none focus:border-cs-primary';
|
|
1526
|
+
const baseStyle = 'transition: border-color 150ms ease;';
|
|
1527
|
+
|
|
1528
|
+
if (forcePassword || fType === 'password') {
|
|
1529
|
+
inputHtml = `<input type="password" id="field-${escAttr(f.name)}" value="${escAttr(value)}" placeholder="••••••••"
|
|
1530
|
+
class="${baseCls}" style="${baseStyle}" autocomplete="new-password"/>`;
|
|
1531
|
+
} else if (fType === 'email') {
|
|
1532
|
+
inputHtml = `<input type="email" id="field-${escAttr(f.name)}" value="${escAttr(value)}" placeholder="${escAttr(f.name)}"
|
|
1533
|
+
class="${baseCls}" style="${baseStyle}" autocomplete="email"/>`;
|
|
1534
|
+
} else if (fType === 'date') {
|
|
1535
|
+
inputHtml = `<input type="date" id="field-${escAttr(f.name)}" value="${escAttr(value)}"
|
|
1536
|
+
class="${baseCls}" style="${baseStyle}"/>`;
|
|
1537
|
+
} else if (fType === 'timestamp') {
|
|
1538
|
+
// Convert ISO string to datetime-local format if needed
|
|
1539
|
+
let dtVal = value;
|
|
1540
|
+
if (dtVal && dtVal.includes('T')) dtVal = dtVal.slice(0, 16);
|
|
1541
|
+
inputHtml = `<input type="datetime-local" id="field-${escAttr(f.name)}" value="${escAttr(dtVal)}"
|
|
1542
|
+
class="${baseCls}" style="${baseStyle}"/>`;
|
|
1543
|
+
} else if (fType === 'integer' || fType === 'number' || fType === 'float' || fType === 'real' || fType === 'money') {
|
|
1544
|
+
inputHtml = `<input type="number" id="field-${escAttr(f.name)}" value="${escAttr(value)}" placeholder="0"
|
|
1545
|
+
class="${baseCls}" style="${baseStyle}" ${fType === 'integer' ? 'step="1"' : 'step="any"'}/>`;
|
|
1546
|
+
} else if (fType === 'boolean') {
|
|
1547
|
+
const checked = value === 'true' || value === '1' || value === true;
|
|
1548
|
+
inputHtml = `<label class="flex items-center gap-2 cursor-pointer">
|
|
1549
|
+
<input type="checkbox" id="field-${escAttr(f.name)}" ${checked ? 'checked' : ''} style="accent-color:${escAttr(currentPrimaryColor)};width:16px;height:16px;" />
|
|
1550
|
+
<span class="text-sm text-cs-muted">${escHtml(f.name)}</span>
|
|
1551
|
+
</label>`;
|
|
1552
|
+
return `<div class="mb-4">${inputHtml}</div>`;
|
|
1553
|
+
} else if (fType === 'text' || fType === 'richText' || fType === 'json') {
|
|
1554
|
+
inputHtml = `<textarea id="field-${escAttr(f.name)}" placeholder="${escAttr(f.name)}"
|
|
1555
|
+
class="${baseCls} resize-y" style="${baseStyle} min-height:80px;">${escHtml(value)}</textarea>`;
|
|
1556
|
+
} else if (fType === 'id') {
|
|
1557
|
+
// ID field with click-to-select picker button
|
|
1558
|
+
const relName = f.relEntity ? f.relEntity.name : f.name.replace(/_id$/, '');
|
|
1559
|
+
const mainProp = f.relEntity && f.relEntity.mainProp ? f.relEntity.mainProp : 'id';
|
|
1560
|
+
inputHtml = `<div class="flex gap-1">
|
|
1561
|
+
<input type="text" id="field-${escAttr(f.name)}" value="${escAttr(value)}" placeholder="Record ID"
|
|
1562
|
+
class="${baseCls} flex-1" style="${baseStyle}" list="dl-${escAttr(f.name)}" oninput="onIdFieldInput('${escAttr(f.name)}','${escAttr(relName)}','${escAttr(mainProp)}')"/>
|
|
1563
|
+
<datalist id="dl-${escAttr(f.name)}"></datalist>
|
|
1564
|
+
<button type="button" onclick="openIdPicker('${escAttr(f.name)}','${escAttr(relName)}','${escAttr(mainProp)}')"
|
|
1565
|
+
class="text-xs border border-cs-border rounded px-2 py-1 text-cs-muted hover:text-cs-text cursor-pointer whitespace-nowrap" style="transition: color 150ms;" title="Select record">Select</button>
|
|
1566
|
+
</div>`;
|
|
1567
|
+
} else {
|
|
1568
|
+
inputHtml = `<input type="text" id="field-${escAttr(f.name)}" value="${escAttr(value)}" placeholder="${escAttr(f.name)}"
|
|
1569
|
+
class="${baseCls}" style="${baseStyle}"/>`;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
return `<div class="mb-4">
|
|
1573
|
+
<label class="block text-sm text-cs-muted mb-1">${escHtml(f.name)}${fType !== 'string' && fType !== 'text' ? ` <span class="text-[10px] opacity-60">(${fType})</span>` : ''}</label>
|
|
1574
|
+
${inputHtml}
|
|
1575
|
+
</div>`;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function openAddModal() {
|
|
1579
|
+
if (!currentItem) return;
|
|
1580
|
+
editingRecord = null;
|
|
1581
|
+
const isCollection = currentItem.type === 'collection';
|
|
1582
|
+
document.getElementById('modal-title').textContent = `New ${currentItem.name}`;
|
|
1583
|
+
const fields = getFields(isCollection);
|
|
1584
|
+
document.getElementById('modal-body').innerHTML = fields.map(f => fieldHtml(f, '')).join('');
|
|
1585
|
+
// Autofill ID fields that refer to an authenticable (user) entity with the logged-in user's ID
|
|
1586
|
+
if (authUser && authUser.id) {
|
|
1587
|
+
const userCollectionNames = new Set((schema.userCollections || []).map(uc => uc.name));
|
|
1588
|
+
for (const f of fields) {
|
|
1589
|
+
if (f.type === 'id' && f.relEntity && (f.relEntity.authenticable || userCollectionNames.has(f.relEntity.name))) {
|
|
1590
|
+
const el = document.getElementById(`field-${f.name}`);
|
|
1591
|
+
if (el && !el.value) el.value = String(authUser.id);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
showModal();
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function openEditModal(record) {
|
|
1599
|
+
if (!currentItem) return;
|
|
1600
|
+
editingRecord = record;
|
|
1601
|
+
const isCollection = currentItem.type === 'collection';
|
|
1602
|
+
document.getElementById('modal-title').textContent = `Edit ${currentItem.name} #${record.id}`;
|
|
1603
|
+
document.getElementById('modal-body').innerHTML =
|
|
1604
|
+
getFields(isCollection).filter(f => f.type !== 'password' && f.name !== 'password').map(f => fieldHtml(f, String(record[f.name] ?? ''))).join('');
|
|
1605
|
+
showModal();
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function showModal() {
|
|
1609
|
+
document.getElementById('modal-backdrop').classList.remove('hidden');
|
|
1610
|
+
const btn = document.getElementById('modal-save-btn');
|
|
1611
|
+
btn.disabled = false; btn.querySelector('span').textContent = 'Save';
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function closeModal() {
|
|
1615
|
+
document.getElementById('modal-backdrop').classList.add('hidden');
|
|
1616
|
+
editingRecord = null;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function getFieldValue(f) {
|
|
1620
|
+
const el = document.getElementById(`field-${f.name}`);
|
|
1621
|
+
if (!el) return undefined;
|
|
1622
|
+
const fType = f.type || 'string';
|
|
1623
|
+
if (fType === 'boolean') return el.checked ? true : false;
|
|
1624
|
+
const val = el.value;
|
|
1625
|
+
if (val === '' || val === undefined) return undefined;
|
|
1626
|
+
if (fType === 'integer') return parseInt(val, 10);
|
|
1627
|
+
if (fType === 'number' || fType === 'float' || fType === 'real' || fType === 'money') return parseFloat(val);
|
|
1628
|
+
return val;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function saveRecord() {
|
|
1632
|
+
if (!currentItem) return;
|
|
1633
|
+
const isCollection = currentItem.type === 'collection';
|
|
1634
|
+
const fields = getFields(isCollection);
|
|
1635
|
+
const editFields = editingRecord ? fields.filter(f => f.type !== 'password' && f.name !== 'password') : fields;
|
|
1636
|
+
const body = {};
|
|
1637
|
+
for (const f of editFields) {
|
|
1638
|
+
const val = getFieldValue(f);
|
|
1639
|
+
if (val !== undefined) body[f.name] = val;
|
|
1640
|
+
}
|
|
1641
|
+
const btn = document.getElementById('modal-save-btn');
|
|
1642
|
+
btn.disabled = true; btn.querySelector('span').textContent = 'Saving…';
|
|
1643
|
+
try {
|
|
1644
|
+
let r;
|
|
1645
|
+
if (editingRecord) {
|
|
1646
|
+
r = await apiFetch(getApiPath(editingRecord.id), { method: 'PATCH', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } });
|
|
1647
|
+
} else if (isCollection) {
|
|
1648
|
+
r = await apiFetch(`/api/auth/${toSlug(currentItem.name)}/signup`, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } });
|
|
1649
|
+
} else {
|
|
1650
|
+
r = await apiFetch(getApiPath(''), { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } });
|
|
1651
|
+
}
|
|
1652
|
+
const data = await r.json();
|
|
1653
|
+
if (!r.ok) throw new Error(data.error || 'Save failed');
|
|
1654
|
+
toast(editingRecord ? 'Record updated' : 'Record created', 'success');
|
|
1655
|
+
closeModal(); reloadTable();
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
toast(err.message, 'error');
|
|
1658
|
+
btn.disabled = false; btn.querySelector('span').textContent = 'Save';
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
async function deleteRecord(id) {
|
|
1663
|
+
if (!currentItem || !confirm(`Delete record #${id}?`)) return;
|
|
1664
|
+
try {
|
|
1665
|
+
const r = await apiFetch(getApiPath(id), { method: 'DELETE' });
|
|
1666
|
+
if (!r.ok) { const d = await r.json(); throw new Error(d.error || 'Delete failed'); }
|
|
1667
|
+
toast('Record deleted', 'success'); reloadTable();
|
|
1668
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
1672
|
+
function apiFetch(url, opts = {}) {
|
|
1673
|
+
return fetch(url, { ...opts, headers: { ...(opts.headers || {}), ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) } });
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function toast(msg, type = 'info') {
|
|
1677
|
+
const el = document.getElementById('toast');
|
|
1678
|
+
const cls = { error: 'border-red-500/60 text-red-400', success: 'border-cs-primary/60 text-cs-primary', info: 'border-cs-border text-cs-text' };
|
|
1679
|
+
el.className = `fixed bottom-5 right-5 z-50 bg-cs-surface border rounded px-4 py-2.5 text-sm max-w-xs ${cls[type] || cls.info}`;
|
|
1680
|
+
el.style.boxShadow = '0 2px 8px rgba(0,0,0,.3)';
|
|
1681
|
+
el.textContent = msg;
|
|
1682
|
+
clearTimeout(toastTimer);
|
|
1683
|
+
toastTimer = setTimeout(() => { el.className = 'hidden'; }, 3000);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
1687
|
+
function escAttr(s) { return String(s).replace(/"/g,'"'); }
|
|
1688
|
+
function toKebab(s) { return s.replace(/([A-Z])/g,(m,p,o)=>(o>0?'-':'')+p.toLowerCase()).replace(/^-/,''); }
|
|
1689
|
+
function toSlug(s) { return s.replace(/([A-Z])/g,(m,p,o)=>(o>0?'-':'')+p.toLowerCase()).replace(/^-/,'').replace(/[^a-z0-9-]/g,'-'); }
|
|
1690
|
+
function toSnake(s) { return s.replace(/([A-Z])/g,(m,p,o)=>(o>0?'_':'')+p.toLowerCase()).replace(/^_/,''); }
|
|
1691
|
+
|
|
1692
|
+
// ── Config Editor ─────────────────────────────────────────────────────────────
|
|
1693
|
+
const CFG_TABS = {
|
|
1694
|
+
general: { keys: ['name','port','database','public'], desc: 'Application name, port, database path, and public static folder.' },
|
|
1695
|
+
entities: { keys: ['entities'], desc: 'Entity definitions — properties, relations, and flags.' },
|
|
1696
|
+
functions: { keys: ['functions'], desc: 'Custom function-based HTTP endpoint definitions (path, method, function).' },
|
|
1697
|
+
files: { keys: ['files'], desc: 'File bucket definitions for local uploads.', s3: true },
|
|
1698
|
+
settings: { keys: ['settings','groups'], desc: 'API rate limits and reusable property groups.' },
|
|
1699
|
+
all: { keys: null, desc: 'Full configuration — all sections combined (raw JSON for advanced use).' },
|
|
1700
|
+
};
|
|
1701
|
+
const CFG_FORM_TABS = new Set(['general','entities','functions','files','settings']);
|
|
1702
|
+
const PROP_TYPES = ['string','text','richText','integer','number','float','money','boolean','email','password','date','timestamp','link','file','image','choice','location','json'];
|
|
1703
|
+
const _INP = 'bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1.5 text-sm outline-none focus:border-cs-primary';
|
|
1704
|
+
const _INP_XS = 'bg-cs-bg border border-cs-border rounded text-cs-text px-2 py-1 text-xs outline-none focus:border-cs-primary';
|
|
1705
|
+
const _LBL = 'block text-xs mb-1';
|
|
1706
|
+
|
|
1707
|
+
async function loadConfig() {
|
|
1708
|
+
try {
|
|
1709
|
+
const r = await apiFetch('/admin/config');
|
|
1710
|
+
if (!r.ok) throw new Error('Failed to load config');
|
|
1711
|
+
configData = await r.json();
|
|
1712
|
+
showConfigTab('general');
|
|
1713
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function showConfigTab(tab) {
|
|
1717
|
+
configActiveTab = tab;
|
|
1718
|
+
document.querySelectorAll('.cfg-tab').forEach(b => b.classList.remove('active'));
|
|
1719
|
+
document.getElementById(`cfg-tab-${tab}`)?.classList.add('active');
|
|
1720
|
+
document.getElementById('cfg-section-desc').textContent = CFG_TABS[tab]?.desc || '';
|
|
1721
|
+
document.getElementById('cfg-s3-note').classList.toggle('hidden', !CFG_TABS[tab]?.s3);
|
|
1722
|
+
const form = document.getElementById('cfg-form');
|
|
1723
|
+
const editor = document.getElementById('cfg-editor');
|
|
1724
|
+
if (tab === 'all') {
|
|
1725
|
+
form.classList.add('hidden'); editor.classList.remove('hidden');
|
|
1726
|
+
editor.value = JSON.stringify(configData || {}, null, 2);
|
|
1727
|
+
} else if (CFG_FORM_TABS.has(tab)) {
|
|
1728
|
+
editor.classList.add('hidden'); form.classList.remove('hidden');
|
|
1729
|
+
form.innerHTML = renderFormTab(tab);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function renderFormTab(tab) {
|
|
1734
|
+
if (!configData) return '<p class="text-xs text-cs-muted">Loading…</p>';
|
|
1735
|
+
if (tab === 'general') return renderGeneralForm();
|
|
1736
|
+
if (tab === 'entities') return renderEntitiesForm();
|
|
1737
|
+
if (tab === 'functions') return renderEndpointsForm();
|
|
1738
|
+
if (tab === 'files') return renderFilesForm();
|
|
1739
|
+
if (tab === 'settings') return renderSettingsForm();
|
|
1740
|
+
return '';
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function renderGeneralForm() {
|
|
1744
|
+
const d = configData || {}, pub = d.public || {};
|
|
1745
|
+
function fld(label, id, val, ph, type, hint) {
|
|
1746
|
+
return '<div>'
|
|
1747
|
+
+ '<label class="' + _LBL + '" style="color:var(--color-cs-muted);">' + label + '</label>'
|
|
1748
|
+
+ '<input type="' + (type || 'text') + '" id="' + id + '" value="' + escAttr(String(val != null ? val : '')) + '" placeholder="' + escAttr(ph) + '"'
|
|
1749
|
+
+ ' class="w-full ' + _INP + '" style="transition:border-color 150ms;" />'
|
|
1750
|
+
+ (hint ? '<p class="text-xs mt-1" style="color:var(--color-cs-muted);">' + hint + '</p>' : '')
|
|
1751
|
+
+ '</div>';
|
|
1752
|
+
}
|
|
1753
|
+
return '<div class="space-y-4 max-w-lg">'
|
|
1754
|
+
+ fld(t('config.general.app_name') + ' <span style="color:#f87171;">*</span>', 'cfg-f-name', d.name || '', 'My App', 'text')
|
|
1755
|
+
+ fld(t('config.general.port'), 'cfg-f-port', d.port !== undefined ? d.port : '', '3000', 'number', t('config.general.port_hint'))
|
|
1756
|
+
+ fld(t('config.general.database'), 'cfg-f-database', d.database || '', t('config.general.database_placeholder'), 'text', t('config.general.database_hint'))
|
|
1757
|
+
+ fld(t('config.general.public_folder'), 'cfg-f-public-folder', pub.folder || '', t('config.general.public_folder_placeholder'), 'text', t('config.general.public_folder_hint'))
|
|
1758
|
+
+ '</div>';
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function collectGeneralForm() {
|
|
1762
|
+
const name = document.getElementById('cfg-f-name').value.trim();
|
|
1763
|
+
const portStr = document.getElementById('cfg-f-port').value.trim();
|
|
1764
|
+
const db = document.getElementById('cfg-f-database').value.trim();
|
|
1765
|
+
const folder = document.getElementById('cfg-f-public-folder').value.trim();
|
|
1766
|
+
const result = { name: name || (configData && configData.name) || 'App' };
|
|
1767
|
+
if (portStr) result.port = parseInt(portStr, 10);
|
|
1768
|
+
if (db) result.database = db;
|
|
1769
|
+
if (folder) result.public = { folder };
|
|
1770
|
+
return result;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function renderEntitiesForm() {
|
|
1774
|
+
_cfgEntityId = 0;
|
|
1775
|
+
const entities = (configData && configData.entities) || {};
|
|
1776
|
+
let cards = '';
|
|
1777
|
+
for (const key of Object.keys(entities)) cards += renderEntityCard(key, entities[key]);
|
|
1778
|
+
return '<div id="cfg-entities-list" class="space-y-3">' + cards + '</div>'
|
|
1779
|
+
+ '<button type="button" onclick="addEntityCard()" class="mt-3 text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer" style="transition:color 150ms,background 150ms;">+ Add entity</button>';
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function _propTypeSelect(cls, sel) {
|
|
1783
|
+
let opts = '';
|
|
1784
|
+
for (const t of PROP_TYPES) opts += '<option value="' + t + '"' + (t === sel ? ' selected' : '') + '>' + t + '</option>';
|
|
1785
|
+
return '<select class="' + cls + '" style="transition:border-color 150ms;">' + opts + '</select>';
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function renderEntityCard(entityName, def) {
|
|
1789
|
+
const id = _cfgEntityId++;
|
|
1790
|
+
const props = def.properties || [];
|
|
1791
|
+
const btArr = (def.belongsTo || []).map(r => typeof r === 'string' ? r : (r.entity || '')).filter(Boolean);
|
|
1792
|
+
const btmArr = (def.belongsToMany || []).map(r => typeof r === 'string' ? r : (r.entity || '')).filter(Boolean);
|
|
1793
|
+
let propsHtml = '';
|
|
1794
|
+
for (const p of props) propsHtml += renderPropRow(typeof p === 'string' ? p : (p.name || ''), typeof p === 'string' ? 'string' : (p.type || 'string'), id);
|
|
1795
|
+
return '<div class="bg-cs-bg border border-cs-border rounded p-3" data-entity-id="' + id + '">'
|
|
1796
|
+
+ '<div class="flex items-center gap-2 mb-3">'
|
|
1797
|
+
+ '<input type="text" placeholder="EntityName" value="' + escAttr(entityName) + '" data-role="entity-name" class="' + _INP + ' flex-1" style="transition:border-color 150ms;" />'
|
|
1798
|
+
+ '<label class="flex items-center gap-1 text-xs text-cs-muted cursor-pointer"><input type="checkbox"' + (def.authenticable ? ' checked' : '') + ' data-role="authenticable" style="accent-color:#34b1eb;" /> Auth</label>'
|
|
1799
|
+
+ '<label class="flex items-center gap-1 text-xs text-cs-muted cursor-pointer"><input type="checkbox"' + (def.single ? ' checked' : '') + ' data-role="single" style="accent-color:#34b1eb;" /> Single</label>'
|
|
1800
|
+
+ '<button type="button" onclick="removeEntityCard(' + id + ')" class="text-cs-muted hover:text-red-400 text-xs cursor-pointer px-1">✕</button></div>'
|
|
1801
|
+
+ '<div class="text-xs text-cs-muted mb-1">Properties</div>'
|
|
1802
|
+
+ '<div id="cfg-props-' + id + '" class="space-y-1">' + propsHtml + '</div>'
|
|
1803
|
+
+ '<button type="button" onclick="addPropRow(' + id + ')" class="mt-1 text-xs text-cs-muted hover:text-cs-text cursor-pointer">+ Add property</button>'
|
|
1804
|
+
+ '<div class="mt-2"><div class="text-xs text-cs-muted mb-1">belongsTo</div>'
|
|
1805
|
+
+ '<input type="text" value="' + escAttr(btArr.join(', ')) + '" data-role="belongsTo" class="w-full ' + _INP_XS + '" style="transition:border-color 150ms;" /></div>'
|
|
1806
|
+
+ '<div class="mt-2"><div class="text-xs text-cs-muted mb-1">belongsToMany</div>'
|
|
1807
|
+
+ '<input type="text" value="' + escAttr(btmArr.join(', ')) + '" data-role="belongsToMany" class="w-full ' + _INP_XS + '" style="transition:border-color 150ms;" /></div>'
|
|
1808
|
+
+ '</div>';
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function renderPropRow(name, type, entityId) {
|
|
1812
|
+
return '<div class="flex gap-1 items-center"><input type="text" placeholder="name" value="' + escAttr(name) + '" data-role="prop-name" class="' + _INP_XS + ' flex-1" style="transition:border-color 150ms;" />'
|
|
1813
|
+
+ _propTypeSelect(_INP_XS + ' w-32', type)
|
|
1814
|
+
+ '<button type="button" onclick="this.parentElement.remove()" class="text-cs-muted hover:text-red-400 text-xs cursor-pointer px-0.5">✕</button></div>';
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function addPropRow(entityId) {
|
|
1818
|
+
const c = document.getElementById('cfg-props-' + entityId); if (!c) return;
|
|
1819
|
+
const d = document.createElement('div'); d.innerHTML = renderPropRow('', 'string', entityId);
|
|
1820
|
+
c.appendChild(d.firstElementChild);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function addEntityCard() {
|
|
1824
|
+
const l = document.getElementById('cfg-entities-list'); if (!l) return;
|
|
1825
|
+
const d = document.createElement('div'); d.innerHTML = renderEntityCard('', {});
|
|
1826
|
+
l.appendChild(d.firstElementChild);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function removeEntityCard(id) {
|
|
1830
|
+
document.querySelector(`[data-entity-id="${id}"]`)?.remove();
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function collectEntitiesForm() {
|
|
1834
|
+
const entities = {};
|
|
1835
|
+
for (const card of document.querySelectorAll('[data-entity-id]')) {
|
|
1836
|
+
const name = card.querySelector('[data-role=entity-name]')?.value.trim();
|
|
1837
|
+
if (!name) continue;
|
|
1838
|
+
const authenticable = card.querySelector('[data-role=authenticable]')?.checked || false;
|
|
1839
|
+
const single = card.querySelector('[data-role=single]')?.checked || false;
|
|
1840
|
+
const props = [];
|
|
1841
|
+
for (const row of card.querySelectorAll('[data-role=prop-name]')) {
|
|
1842
|
+
const pName = row.value.trim(), pType = row.nextElementSibling?.value || 'string';
|
|
1843
|
+
if (pName) props.push(pType === 'string' ? pName : { name: pName, type: pType });
|
|
1844
|
+
}
|
|
1845
|
+
const bt = (card.querySelector('[data-role=belongsTo]')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1846
|
+
const btm = (card.querySelector('[data-role=belongsToMany]')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1847
|
+
const def = { properties: props };
|
|
1848
|
+
if (authenticable) def.authenticable = true;
|
|
1849
|
+
if (single) def.single = true;
|
|
1850
|
+
if (bt.length) def.belongsTo = bt;
|
|
1851
|
+
if (btm.length) def.belongsToMany = btm;
|
|
1852
|
+
entities[name] = def;
|
|
1853
|
+
}
|
|
1854
|
+
return entities;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function renderEndpointsForm() {
|
|
1858
|
+
_cfgEndpointId = 0;
|
|
1859
|
+
_cfgTriggerId = 0;
|
|
1860
|
+
const endpoints = (configData && configData.functions) || {};
|
|
1861
|
+
let cards = '';
|
|
1862
|
+
for (const [k, ep] of Object.entries(endpoints)) cards += renderEndpointCard(k, ep);
|
|
1863
|
+
return '<div id="cfg-endpoints-list" class="space-y-3">' + cards + '</div>'
|
|
1864
|
+
+ '<button type="button" onclick="addEndpointCard()" class="mt-3 text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer">+ Add endpoint</button>';
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function cronHuman(schedule) {
|
|
1868
|
+
try { return (typeof cronstrue !== 'undefined') ? cronstrue.toString(schedule) : schedule; }
|
|
1869
|
+
catch { return schedule; }
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function renderTriggerBadges(ep) {
|
|
1873
|
+
const triggers = ep.triggers || [];
|
|
1874
|
+
return triggers.map(t => {
|
|
1875
|
+
if (t.type === 'http') return '<span class="px-1.5 py-0.5 rounded text-xs" style="background:rgba(52,177,235,.15);color:#34b1eb;">'
|
|
1876
|
+
+ escAttr(t.method || 'GET') + ' ' + escAttr(t.path || '') + '</span>';
|
|
1877
|
+
if (t.type === 'cron') return '<span class="px-1.5 py-0.5 rounded text-xs" title="' + escAttr(cronHuman(t.schedule||'')) + '" style="background:rgba(3,218,198,.15);color:#03dac6;">cron: '
|
|
1878
|
+
+ escAttr(t.schedule || '') + '</span>';
|
|
1879
|
+
if (t.type === 'event') return '<span class="px-1.5 py-0.5 rounded text-xs" style="background:rgba(187,134,252,.15);color:#bb86fc;">event: '
|
|
1880
|
+
+ escAttr(t.name || '') + '</span>';
|
|
1881
|
+
return '';
|
|
1882
|
+
}).join(' ');
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function renderTriggerRow(epId, tId, trigger) {
|
|
1886
|
+
const type = trigger.type || 'http';
|
|
1887
|
+
const method = trigger.method || 'GET';
|
|
1888
|
+
const path = trigger.path || '';
|
|
1889
|
+
const sched = trigger.schedule || '';
|
|
1890
|
+
const evtName = trigger.name || '';
|
|
1891
|
+
const policy = (trigger.policies && trigger.policies[0]) ? (trigger.policies[0].access || 'public') : 'public';
|
|
1892
|
+
const typeOpts = ['http','cron','event'].map(t =>
|
|
1893
|
+
'<option value="' + t + '"' + (t === type ? ' selected' : '') + '>' + t + '</option>').join('');
|
|
1894
|
+
const methodOpts = ['GET','POST','PUT','PATCH','DELETE'].map(m =>
|
|
1895
|
+
'<option value="' + m + '"' + (m === method ? ' selected' : '') + '>' + m + '</option>').join('');
|
|
1896
|
+
const policyOpts = ['public','restricted','admin','forbidden'].map(a =>
|
|
1897
|
+
'<option value="' + a + '"' + (a === policy ? ' selected' : '') + '>' + a + '</option>').join('');
|
|
1898
|
+
return '<div class="flex flex-wrap gap-1.5 items-center p-2 rounded" style="background:var(--color-cs-bg);border:1px solid var(--color-cs-border);" data-trigger-id="' + tId + '">'
|
|
1899
|
+
+ '<select data-role="tr-type" onchange="_onTrTypeChange(this)" class="' + _INP_XS + '" style="width:64px;">' + typeOpts + '</select>'
|
|
1900
|
+
// HTTP fields
|
|
1901
|
+
+ '<select data-role="tr-method" class="' + _INP_XS + '" style="width:74px;' + (type !== 'http' ? 'display:none;' : '') + '">' + methodOpts + '</select>'
|
|
1902
|
+
+ '<input type="text" data-role="tr-path" placeholder="/path" value="' + escAttr(path) + '" class="' + _INP_XS + ' flex-1" style="min-width:90px;' + (type !== 'http' ? 'display:none;' : '') + '" />'
|
|
1903
|
+
+ '<select data-role="tr-policy" class="' + _INP_XS + '" title="Access policy" style="width:88px;' + (type !== 'http' ? 'display:none;' : '') + '">' + policyOpts + '</select>'
|
|
1904
|
+
// Cron fields
|
|
1905
|
+
+ '<input type="text" data-role="tr-schedule" placeholder="* * * * * or @daily" value="' + escAttr(sched) + '" class="' + _INP_XS + ' flex-1" style="min-width:130px;' + (type !== 'cron' ? 'display:none;' : '') + '" />'
|
|
1906
|
+
// Event fields
|
|
1907
|
+
+ '<input type="text" data-role="tr-event" placeholder="event.name" value="' + escAttr(evtName) + '" class="' + _INP_XS + ' flex-1" style="min-width:130px;' + (type !== 'event' ? 'display:none;' : '') + '" />'
|
|
1908
|
+
+ '<button type="button" onclick="this.closest(\'[data-trigger-id]\').remove()" class="text-cs-muted hover:text-red-400 text-xs cursor-pointer" title="Remove trigger">✕</button>'
|
|
1909
|
+
+ '</div>';
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function _onTrTypeChange(sel) {
|
|
1913
|
+
const row = sel.closest('[data-trigger-id]');
|
|
1914
|
+
const type = sel.value;
|
|
1915
|
+
const show = (role) => { const el = row.querySelector('[data-role=' + role + ']'); if (el) el.style.display = ''; };
|
|
1916
|
+
const hide = (role) => { const el = row.querySelector('[data-role=' + role + ']'); if (el) el.style.display = 'none'; };
|
|
1917
|
+
['tr-method','tr-path','tr-policy'].forEach(hide);
|
|
1918
|
+
['tr-schedule'].forEach(hide);
|
|
1919
|
+
['tr-event'].forEach(hide);
|
|
1920
|
+
if (type === 'http') { show('tr-method'); show('tr-path'); show('tr-policy'); }
|
|
1921
|
+
if (type === 'cron') { show('tr-schedule'); }
|
|
1922
|
+
if (type === 'event') { show('tr-event'); }
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function addTriggerToEndpoint(epId) {
|
|
1926
|
+
const card = document.querySelector('[data-ep-id="' + epId + '"]');
|
|
1927
|
+
if (!card) return;
|
|
1928
|
+
const list = card.querySelector('[data-role=trigger-list]');
|
|
1929
|
+
if (!list) return;
|
|
1930
|
+
const tId = _cfgTriggerId++;
|
|
1931
|
+
const d = document.createElement('div');
|
|
1932
|
+
d.innerHTML = renderTriggerRow(epId, tId, {});
|
|
1933
|
+
list.appendChild(d.firstElementChild);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function renderEndpointCard(name, ep) {
|
|
1937
|
+
const id = _cfgEndpointId++;
|
|
1938
|
+
const runtimes = ['js','bash','python','go','c++','ruby','php'];
|
|
1939
|
+
const rOpts = runtimes.map(r => '<option value="' + r + '"' + (r === (ep.runtime||'js') ? ' selected' : '') + '>' + r + '</option>').join('');
|
|
1940
|
+
const triggers = ep.triggers || [];
|
|
1941
|
+
let triggersHtml = '';
|
|
1942
|
+
for (const tr of triggers) { const tId = _cfgTriggerId++; triggersHtml += renderTriggerRow(id, tId, tr); }
|
|
1943
|
+
return '<div class="bg-cs-bg border border-cs-border rounded p-3 space-y-2" data-ep-id="' + id + '">'
|
|
1944
|
+
+ '<div class="flex gap-2 items-center"><input type="text" placeholder="Name" value="' + escAttr(name) + '" data-role="ep-name" class="' + _INP_XS + ' flex-1" />'
|
|
1945
|
+
+ '<select data-role="ep-runtime" class="' + _INP_XS + '" title="Runtime">' + rOpts + '</select>'
|
|
1946
|
+
+ '<button type="button" onclick="this.closest(\'[data-ep-id]\').remove()" class="text-cs-muted hover:text-red-400 text-xs cursor-pointer">✕</button></div>'
|
|
1947
|
+
+ '<input type="text" placeholder="function.js" value="' + escAttr(ep.function||'') + '" data-role="ep-function" class="w-full ' + _INP_XS + '" />'
|
|
1948
|
+
+ '<div class="space-y-1.5" data-role="trigger-list">' + triggersHtml + '</div>'
|
|
1949
|
+
+ '<button type="button" onclick="addTriggerToEndpoint(' + id + ')" class="text-xs text-cs-muted hover:text-cs-text cursor-pointer" style="background:transparent;border:none;padding:0;">+ Add trigger</button>'
|
|
1950
|
+
+ '</div>';
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function addEndpointCard() {
|
|
1954
|
+
const l = document.getElementById('cfg-endpoints-list'); if (!l) return;
|
|
1955
|
+
const d = document.createElement('div'); d.innerHTML = renderEndpointCard('', {});
|
|
1956
|
+
l.appendChild(d.firstElementChild);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function collectEndpointsForm() {
|
|
1960
|
+
const endpoints = {};
|
|
1961
|
+
for (const card of document.querySelectorAll('[data-ep-id]')) {
|
|
1962
|
+
const name = card.querySelector('[data-role=ep-name]')?.value.trim();
|
|
1963
|
+
const fn = card.querySelector('[data-role=ep-function]')?.value.trim();
|
|
1964
|
+
const runtime = card.querySelector('[data-role=ep-runtime]')?.value || 'js';
|
|
1965
|
+
const triggers = [];
|
|
1966
|
+
for (const row of card.querySelectorAll('[data-trigger-id]')) {
|
|
1967
|
+
const type = row.querySelector('[data-role=tr-type]')?.value || 'http';
|
|
1968
|
+
if (type === 'http') {
|
|
1969
|
+
const method = row.querySelector('[data-role=tr-method]')?.value || 'GET';
|
|
1970
|
+
const path = row.querySelector('[data-role=tr-path]')?.value.trim() || '';
|
|
1971
|
+
const access = row.querySelector('[data-role=tr-policy]')?.value || 'public';
|
|
1972
|
+
const trigger = { type, method, path };
|
|
1973
|
+
if (access !== 'public') trigger.policies = [{ access }];
|
|
1974
|
+
if (path) triggers.push(trigger);
|
|
1975
|
+
} else if (type === 'cron') {
|
|
1976
|
+
const schedule = row.querySelector('[data-role=tr-schedule]')?.value.trim() || '';
|
|
1977
|
+
if (schedule) triggers.push({ type, schedule });
|
|
1978
|
+
} else if (type === 'event') {
|
|
1979
|
+
const evtName = row.querySelector('[data-role=tr-event]')?.value.trim() || '';
|
|
1980
|
+
if (evtName) triggers.push({ type, name: evtName });
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
if (name && fn) endpoints[name] = { runtime, function: fn, triggers };
|
|
1984
|
+
}
|
|
1985
|
+
return endpoints;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function renderFilesForm() {
|
|
1989
|
+
_cfgFileId = 0;
|
|
1990
|
+
const files = (configData && configData.files) || {};
|
|
1991
|
+
let cards = '';
|
|
1992
|
+
for (const [k, f] of Object.entries(files)) cards += renderFileCard(k, f);
|
|
1993
|
+
return '<div id="cfg-files-list" class="space-y-3">' + cards + '</div>'
|
|
1994
|
+
+ '<button type="button" onclick="addFileCard()" class="mt-3 text-xs border border-cs-border rounded px-3 py-1.5 text-cs-muted hover:text-cs-text cursor-pointer">+ Add bucket</button>';
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function renderFileCard(name, f) {
|
|
1998
|
+
const id = _cfgFileId++;
|
|
1999
|
+
return '<div class="bg-cs-bg border border-cs-border rounded p-3 space-y-2" data-file-id="' + id + '">'
|
|
2000
|
+
+ '<div class="flex gap-2 items-center"><input type="text" placeholder="Bucket name" value="' + escAttr(name) + '" data-role="file-name" class="' + _INP_XS + ' flex-1" />'
|
|
2001
|
+
+ '<button type="button" onclick="this.closest(\'[data-file-id]\').remove()" class="text-cs-muted hover:text-red-400 text-xs cursor-pointer">✕</button></div>'
|
|
2002
|
+
+ '<input type="text" placeholder="./uploads" value="' + escAttr(f.path||'') + '" data-role="file-path" class="w-full ' + _INP_XS + '" />'
|
|
2003
|
+
+ '<label class="flex items-center gap-1 text-xs text-cs-muted cursor-pointer"><input type="checkbox"' + (f.public ? ' checked' : '') + ' data-role="file-public" style="accent-color:#34b1eb;" /> Public</label>'
|
|
2004
|
+
+ '</div>';
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function addFileCard() {
|
|
2008
|
+
const l = document.getElementById('cfg-files-list'); if (!l) return;
|
|
2009
|
+
const d = document.createElement('div'); d.innerHTML = renderFileCard('', {});
|
|
2010
|
+
l.appendChild(d.firstElementChild);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function collectFilesForm() {
|
|
2014
|
+
const files = {};
|
|
2015
|
+
for (const card of document.querySelectorAll('[data-file-id]')) {
|
|
2016
|
+
const name = card.querySelector('[data-role=file-name]')?.value.trim();
|
|
2017
|
+
const path = card.querySelector('[data-role=file-path]')?.value.trim();
|
|
2018
|
+
const pub = card.querySelector('[data-role=file-public]')?.checked || false;
|
|
2019
|
+
if (name) files[name] = { path: path || './uploads', public: pub };
|
|
2020
|
+
}
|
|
2021
|
+
return files;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function renderSettingsForm() {
|
|
2025
|
+
_cfgRlId = 0;
|
|
2026
|
+
const settings = (configData && configData.settings) || {};
|
|
2027
|
+
const rls = settings.rateLimits || [];
|
|
2028
|
+
let rlHtml = '';
|
|
2029
|
+
for (const rl of rls) rlHtml += renderRlRow(rl);
|
|
2030
|
+
return '<div class="space-y-3"><div><div class="text-xs text-cs-muted mb-2">Rate Limits</div>'
|
|
2031
|
+
+ '<div id="cfg-rl-list" class="space-y-2">' + rlHtml + '</div>'
|
|
2032
|
+
+ '<button type="button" onclick="addRlRow()" class="mt-1 text-xs border border-cs-border rounded px-2 py-1 text-cs-muted hover:text-cs-text cursor-pointer">+ Add rule</button></div></div>';
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function renderRlRow(rl) {
|
|
2036
|
+
const id = _cfgRlId++;
|
|
2037
|
+
return '<div class="flex gap-2 items-center" data-rl-id="' + id + '">'
|
|
2038
|
+
+ '<input type="number" placeholder="TTL ms" value="' + escAttr(String(rl.ttl||60000)) + '" data-role="rl-ttl" class="' + _INP_XS + ' w-28" />'
|
|
2039
|
+
+ '<input type="number" placeholder="Max requests" value="' + escAttr(String(rl.limit||100)) + '" data-role="rl-limit" class="' + _INP_XS + ' w-28" />'
|
|
2040
|
+
+ '<button type="button" onclick="this.parentElement.remove()" class="text-cs-muted hover:text-red-400 text-xs cursor-pointer">✕</button></div>';
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function addRlRow() {
|
|
2044
|
+
const l = document.getElementById('cfg-rl-list'); if (!l) return;
|
|
2045
|
+
const d = document.createElement('div'); d.innerHTML = renderRlRow({});
|
|
2046
|
+
l.appendChild(d.firstElementChild);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function collectSettingsForm() {
|
|
2050
|
+
const rls = [];
|
|
2051
|
+
for (const row of document.querySelectorAll('[data-rl-id]')) {
|
|
2052
|
+
const ttl = parseInt(row.querySelector('[data-role=rl-ttl]')?.value || '60000', 10);
|
|
2053
|
+
const limit = parseInt(row.querySelector('[data-role=rl-limit]')?.value || '100', 10);
|
|
2054
|
+
if (!isNaN(ttl) && !isNaN(limit)) rls.push({ ttl, limit });
|
|
2055
|
+
}
|
|
2056
|
+
return rls.length ? { rateLimits: rls } : {};
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function collectConfigForm() {
|
|
2060
|
+
if (configActiveTab === 'all') return JSON.parse(document.getElementById('cfg-editor').value);
|
|
2061
|
+
const base = { ...(configData || {}) };
|
|
2062
|
+
if (configActiveTab === 'general') Object.assign(base, collectGeneralForm());
|
|
2063
|
+
if (configActiveTab === 'entities') base.entities = collectEntitiesForm();
|
|
2064
|
+
if (configActiveTab === 'functions') base.functions = collectEndpointsForm();
|
|
2065
|
+
if (configActiveTab === 'files') base.files = collectFilesForm();
|
|
2066
|
+
if (configActiveTab === 'settings') base.settings = collectSettingsForm();
|
|
2067
|
+
return base;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
async function saveConfig() {
|
|
2071
|
+
const btn = document.getElementById('cfg-save-btn');
|
|
2072
|
+
const status = document.getElementById('cfg-status');
|
|
2073
|
+
btn.disabled = true; status.textContent = 'Saving…';
|
|
2074
|
+
try {
|
|
2075
|
+
const newCfg = collectConfigForm();
|
|
2076
|
+
const r = await apiFetch('/admin/config', { method: 'PUT', body: JSON.stringify(newCfg), headers: { 'Content-Type': 'application/json' } });
|
|
2077
|
+
const data = await r.json();
|
|
2078
|
+
if (!r.ok) throw new Error(data.error || 'Save failed');
|
|
2079
|
+
status.textContent = data.message || 'Saved!';
|
|
2080
|
+
configData = newCfg;
|
|
2081
|
+
toast(data.message || 'Config saved', 'success');
|
|
2082
|
+
} catch (e) { status.textContent = e.message; toast(e.message, 'error'); }
|
|
2083
|
+
btn.disabled = false;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function resetConfigTab() { showConfigTab(configActiveTab); }
|
|
2087
|
+
|
|
2088
|
+
// ── Theme Settings ────────────────────────────────────────────────────────────
|
|
2089
|
+
const THEME_PALETTE_COLORS = ['#34b1eb','#6366f1','#8b5cf6','#ec4899','#f59e0b','#10b981','#ef4444','#f97316'];
|
|
2090
|
+
|
|
2091
|
+
/** Validate a CSS hex color string — accepts #RGB or #RRGGBB. Returns safe fallback on invalid input. */
|
|
2092
|
+
function sanitizeColor(raw, fallback) {
|
|
2093
|
+
if (typeof raw === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(raw.trim())) return raw.trim();
|
|
2094
|
+
return fallback || '#34b1eb';
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
let currentPrimaryColor = sanitizeColor(localStorage.getItem('cs_primary_color'), '#34b1eb');
|
|
2098
|
+
let currentThemeMode = (localStorage.getItem('cs_theme_mode') === 'light') ? 'light' : 'dark';
|
|
2099
|
+
|
|
2100
|
+
function applyTheme() {
|
|
2101
|
+
const root = document.documentElement;
|
|
2102
|
+
if (currentThemeMode === 'light') {
|
|
2103
|
+
root.style.setProperty('--color-cs-bg', '#f8f9fa');
|
|
2104
|
+
root.style.setProperty('--color-cs-surface', '#ffffff');
|
|
2105
|
+
root.style.setProperty('--color-cs-border', '#e2e8f0');
|
|
2106
|
+
root.style.setProperty('--color-cs-text', '#1a202c');
|
|
2107
|
+
root.style.setProperty('--color-cs-muted', '#64748b');
|
|
2108
|
+
} else {
|
|
2109
|
+
root.style.setProperty('--color-cs-bg', '#121212');
|
|
2110
|
+
root.style.setProperty('--color-cs-surface', '#1e1e1e');
|
|
2111
|
+
root.style.setProperty('--color-cs-border', '#2a2a2a');
|
|
2112
|
+
root.style.setProperty('--color-cs-text', '#e1e1e1');
|
|
2113
|
+
root.style.setProperty('--color-cs-muted', '#888888');
|
|
2114
|
+
}
|
|
2115
|
+
root.style.setProperty('--color-cs-primary', currentPrimaryColor);
|
|
2116
|
+
// Also update inline glow animation and btn colors to reflect new primary
|
|
2117
|
+
document.querySelectorAll('.btn-glow').forEach(el => {
|
|
2118
|
+
el.style.background = `linear-gradient(var(--color-cs-surface),var(--color-cs-surface)) padding-box, conic-gradient(from var(--glow-a),${currentPrimaryColor} 0%,${currentPrimaryColor}88 40%,transparent 60%,${currentPrimaryColor} 100%) border-box`;
|
|
2119
|
+
el.style.color = currentPrimaryColor;
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function openThemeSettings() {
|
|
2124
|
+
// Build palette
|
|
2125
|
+
const pal = document.getElementById('theme-palette');
|
|
2126
|
+
pal.innerHTML = THEME_PALETTE_COLORS.map(c =>
|
|
2127
|
+
`<button onclick="setPrimaryColor('${c}')" title="${c}"
|
|
2128
|
+
style="width:24px;height:24px;border-radius:50%;background:${c};border:2px solid ${c === currentPrimaryColor ? '#fff' : 'transparent'};cursor:pointer;transition:border-color 150ms;"></button>`
|
|
2129
|
+
).join('');
|
|
2130
|
+
// Set current color picker
|
|
2131
|
+
document.getElementById('theme-custom-color').value = currentPrimaryColor;
|
|
2132
|
+
document.getElementById('theme-custom-hex').textContent = currentPrimaryColor;
|
|
2133
|
+
// Set mode buttons
|
|
2134
|
+
const isDark = currentThemeMode === 'dark';
|
|
2135
|
+
document.getElementById('theme-dark-btn').style.cssText = isDark
|
|
2136
|
+
? `border-color:${currentPrimaryColor};color:${currentPrimaryColor};background:rgba(52,177,235,.1);`
|
|
2137
|
+
: 'border-color:var(--color-cs-border);color:var(--color-cs-muted);background:transparent;';
|
|
2138
|
+
document.getElementById('theme-light-btn').style.cssText = !isDark
|
|
2139
|
+
? `border-color:${currentPrimaryColor};color:${currentPrimaryColor};background:rgba(52,177,235,.1);`
|
|
2140
|
+
: 'border-color:var(--color-cs-border);color:var(--color-cs-muted);background:transparent;';
|
|
2141
|
+
document.getElementById('theme-modal-backdrop').classList.remove('hidden');
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
function closeThemeSettings() {
|
|
2145
|
+
document.getElementById('theme-modal-backdrop').classList.add('hidden');
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function setThemeMode(mode) {
|
|
2149
|
+
currentThemeMode = mode;
|
|
2150
|
+
localStorage.setItem('cs_theme_mode', mode);
|
|
2151
|
+
applyTheme();
|
|
2152
|
+
openThemeSettings(); // re-render to update button states
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function setPrimaryColor(color) {
|
|
2156
|
+
currentPrimaryColor = sanitizeColor(color, '#34b1eb');
|
|
2157
|
+
localStorage.setItem('cs_primary_color', currentPrimaryColor);
|
|
2158
|
+
applyTheme();
|
|
2159
|
+
openThemeSettings(); // re-render palette
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function onCustomColorInput(color) {
|
|
2163
|
+
const safe = sanitizeColor(color, '#34b1eb');
|
|
2164
|
+
document.getElementById('theme-custom-hex').textContent = safe;
|
|
2165
|
+
setPrimaryColor(safe);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// ── Column Visibility ─────────────────────────────────────────────────────────
|
|
2169
|
+
function getColumnPrefsKey(entityName) {
|
|
2170
|
+
return `cs_cols_${entityName}`;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function getHiddenColumns(entityName) {
|
|
2174
|
+
try {
|
|
2175
|
+
const raw = localStorage.getItem(getColumnPrefsKey(entityName));
|
|
2176
|
+
return raw ? JSON.parse(raw) : [];
|
|
2177
|
+
} catch { return []; }
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function openColumnsPanel() {
|
|
2181
|
+
if (!currentItem) return;
|
|
2182
|
+
const entityName = currentItem.name;
|
|
2183
|
+
const s = (schema.entities || []).find(e => e.name === entityName);
|
|
2184
|
+
const hiddenCols = getHiddenColumns(entityName);
|
|
2185
|
+
const allCols = ['id', 'createdAt', 'updatedAt'];
|
|
2186
|
+
if (s) {
|
|
2187
|
+
for (const p of s.properties || []) allCols.push(typeof p === 'string' ? p : p.name);
|
|
2188
|
+
if (s.authenticable) { if (!allCols.includes('email')) allCols.push('email'); }
|
|
2189
|
+
}
|
|
2190
|
+
document.getElementById('columns-entity-name').textContent = entityName;
|
|
2191
|
+
document.getElementById('columns-list').innerHTML = allCols.map(col =>
|
|
2192
|
+
`<label class="flex items-center gap-2 cursor-pointer text-sm" style="color:var(--color-cs-text);">
|
|
2193
|
+
<input type="checkbox" class="col-vis-cb" value="${escAttr(col)}" ${hiddenCols.includes(col) ? '' : 'checked'}
|
|
2194
|
+
style="accent-color:${escAttr(currentPrimaryColor)};width:14px;height:14px;" />
|
|
2195
|
+
${escHtml(col)}
|
|
2196
|
+
</label>`
|
|
2197
|
+
).join('');
|
|
2198
|
+
document.getElementById('columns-modal-backdrop').classList.remove('hidden');
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function closeColumnsPanel() {
|
|
2202
|
+
document.getElementById('columns-modal-backdrop').classList.add('hidden');
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function saveColumnsPrefs() {
|
|
2206
|
+
if (!currentItem) return;
|
|
2207
|
+
const hidden = [];
|
|
2208
|
+
document.querySelectorAll('.col-vis-cb').forEach(cb => {
|
|
2209
|
+
if (!cb.checked) hidden.push(cb.value);
|
|
2210
|
+
});
|
|
2211
|
+
localStorage.setItem(getColumnPrefsKey(currentItem.name), JSON.stringify(hidden));
|
|
2212
|
+
closeColumnsPanel();
|
|
2213
|
+
reloadTable();
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function resetColumnsPrefs() {
|
|
2217
|
+
if (!currentItem) return;
|
|
2218
|
+
localStorage.removeItem(getColumnPrefsKey(currentItem.name));
|
|
2219
|
+
closeColumnsPanel();
|
|
2220
|
+
reloadTable();
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// ── ID Field helpers (autocomplete + lookup) ──────────────────────────────────
|
|
2224
|
+
async function onIdFieldInput(fieldName, relEntityName, mainProp) {
|
|
2225
|
+
const el = document.getElementById(`field-${fieldName}`);
|
|
2226
|
+
if (!el) return;
|
|
2227
|
+
const dl = document.getElementById(`dl-${fieldName}`);
|
|
2228
|
+
if (!dl) return;
|
|
2229
|
+
const q = el.value.trim();
|
|
2230
|
+
if (q.length < 1) { dl.innerHTML = ''; return; }
|
|
2231
|
+
try {
|
|
2232
|
+
const r = await apiFetch(`/admin/data?type=entity&name=${encodeURIComponent(relEntityName)}&perPage=10&search=${encodeURIComponent(q)}`);
|
|
2233
|
+
if (!r.ok) return;
|
|
2234
|
+
const data = await r.json();
|
|
2235
|
+
dl.innerHTML = (data.data || []).map(row => {
|
|
2236
|
+
const label = row[mainProp] || row.name || row.title || row.email || row.id;
|
|
2237
|
+
return `<option value="${escAttr(String(row.id))}" label="${escAttr(String(label))}"></option>`;
|
|
2238
|
+
}).join('');
|
|
2239
|
+
} catch { /* ignore */ }
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
async function lookupIdRecord(fieldName, relEntityName) {
|
|
2243
|
+
const el = document.getElementById(`field-${fieldName}`);
|
|
2244
|
+
if (!el) return;
|
|
2245
|
+
const id = el.value.trim();
|
|
2246
|
+
if (!id) { toast('Enter an ID to look up', 'info'); return; }
|
|
2247
|
+
try {
|
|
2248
|
+
const r = await apiFetch(`/admin/data?type=entity&name=${encodeURIComponent(relEntityName)}&perPage=1&id_eq=${encodeURIComponent(id)}`);
|
|
2249
|
+
if (!r.ok) { toast('Record not found', 'error'); return; }
|
|
2250
|
+
const data = await r.json();
|
|
2251
|
+
const record = data.data && data.data[0];
|
|
2252
|
+
if (!record) { toast('Record not found', 'error'); return; }
|
|
2253
|
+
document.getElementById('lookup-modal-title').textContent = `${relEntityName} #${id}`;
|
|
2254
|
+
document.getElementById('lookup-modal-body').textContent = JSON.stringify(record, null, 2);
|
|
2255
|
+
document.getElementById('lookup-modal-backdrop').classList.remove('hidden');
|
|
2256
|
+
} catch (e) { toast('Lookup failed: ' + e.message, 'error'); }
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function closeLookupModal() {
|
|
2260
|
+
document.getElementById('lookup-modal-backdrop').classList.add('hidden');
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// ── View Record (table action) ─────────────────────────────────────────────────
|
|
2264
|
+
function viewRecord(record) {
|
|
2265
|
+
const entityName = currentItem ? currentItem.name : 'Record';
|
|
2266
|
+
document.getElementById('lookup-modal-title').textContent = `${entityName} #${record.id}`;
|
|
2267
|
+
document.getElementById('lookup-modal-body').textContent = JSON.stringify(record, null, 2);
|
|
2268
|
+
document.getElementById('lookup-modal-backdrop').classList.remove('hidden');
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// ── ID Picker (select related entity record) ──────────────────────────────────
|
|
2272
|
+
let _idPickerField = '', _idPickerEntity = '', _idPickerMainProp = '', _idPickerDebounce = null;
|
|
2273
|
+
|
|
2274
|
+
async function openIdPicker(fieldName, relEntityName, mainProp) {
|
|
2275
|
+
_idPickerField = fieldName;
|
|
2276
|
+
_idPickerEntity = relEntityName;
|
|
2277
|
+
_idPickerMainProp = mainProp || 'id';
|
|
2278
|
+
document.getElementById('id-picker-title').textContent = `Select ${relEntityName}`;
|
|
2279
|
+
document.getElementById('id-picker-search').value = '';
|
|
2280
|
+
document.getElementById('id-picker-list').innerHTML = '';
|
|
2281
|
+
document.getElementById('id-picker-backdrop').classList.remove('hidden');
|
|
2282
|
+
await loadIdPickerResults('');
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function closeIdPicker() {
|
|
2286
|
+
document.getElementById('id-picker-backdrop').classList.add('hidden');
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function onIdPickerSearch() {
|
|
2290
|
+
clearTimeout(_idPickerDebounce);
|
|
2291
|
+
_idPickerDebounce = setTimeout(async () => {
|
|
2292
|
+
const q = document.getElementById('id-picker-search').value.trim();
|
|
2293
|
+
await loadIdPickerResults(q);
|
|
2294
|
+
}, 250);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
async function loadIdPickerResults(q) {
|
|
2298
|
+
const listEl = document.getElementById('id-picker-list');
|
|
2299
|
+
listEl.innerHTML = '<div class="text-xs text-cs-muted text-center py-4">Loading…</div>';
|
|
2300
|
+
try {
|
|
2301
|
+
const params = new URLSearchParams({ type: 'entity', name: _idPickerEntity, perPage: 20 });
|
|
2302
|
+
if (q) params.set('search', q);
|
|
2303
|
+
const r = await apiFetch('/admin/data?' + params.toString());
|
|
2304
|
+
if (!r.ok) throw new Error('Failed to load');
|
|
2305
|
+
const data = await r.json();
|
|
2306
|
+
const rows = data.data || [];
|
|
2307
|
+
if (!rows.length) { listEl.innerHTML = '<div class="text-xs text-cs-muted text-center py-4">No records found</div>'; return; }
|
|
2308
|
+
listEl.innerHTML = rows.map(row => {
|
|
2309
|
+
const humanLabel = (_idPickerMainProp !== 'id' && row[_idPickerMainProp]) || row.title || row.name || row.email;
|
|
2310
|
+
const label = humanLabel || row.id;
|
|
2311
|
+
const safeId = escAttr(String(row.id));
|
|
2312
|
+
const safeLabel = escHtml(String(label));
|
|
2313
|
+
return `<button type="button" onclick="selectIdPickerRow('${safeId}')"
|
|
2314
|
+
class="w-full text-left px-3 py-2 rounded text-sm hover:bg-cs-border cursor-pointer flex items-center justify-between gap-2" style="transition: background 150ms;">
|
|
2315
|
+
<span style="color:var(--color-cs-text);">${safeLabel}</span>
|
|
2316
|
+
<span class="text-xs shrink-0" style="color:var(--color-cs-muted);">#${safeId}</span>
|
|
2317
|
+
</button>`;
|
|
2318
|
+
}).join('');
|
|
2319
|
+
} catch (e) { listEl.innerHTML = '<div class="text-xs text-red-400 text-center py-4">' + escHtml(e.message) + '</div>'; }
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
function selectIdPickerRow(id) {
|
|
2323
|
+
const el = document.getElementById(`field-${_idPickerField}`);
|
|
2324
|
+
if (el) el.value = id;
|
|
2325
|
+
closeIdPicker();
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
2329
|
+
// Apply saved theme on load
|
|
2330
|
+
applyTheme();
|
|
2331
|
+
init();
|
|
2332
|
+
|
|
2333
|
+
function collectFormData() {
|
|
2334
|
+
if (configActiveTab === 'general') return collectGeneralForm();
|
|
2335
|
+
if (configActiveTab === 'entities') return { entities: collectEntitiesForm() };
|
|
2336
|
+
if (configActiveTab === 'functions') return { functions: collectEndpointsForm() };
|
|
2337
|
+
if (configActiveTab === 'files') return { files: collectFilesForm() };
|
|
2338
|
+
if (configActiveTab === 'settings') return collectSettingsForm();
|
|
2339
|
+
return null;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function selectConfig(el) {
|
|
2343
|
+
document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
|
|
2344
|
+
el.classList.add('active');
|
|
2345
|
+
configMode = true;
|
|
2346
|
+
document.getElementById('page-title').textContent = t('config.editor_title');
|
|
2347
|
+
document.getElementById('btn-add').classList.add('hidden');
|
|
2348
|
+
document.getElementById('btn-share').classList.add('hidden');
|
|
2349
|
+
document.getElementById('btn-devs').classList.add('hidden');
|
|
2350
|
+
showOnlyArea('config-area');
|
|
2351
|
+
closeSidebar();
|
|
2352
|
+
loadConfig();
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
async function loadConfig() {
|
|
2356
|
+
document.getElementById('cfg-status').textContent = t('config.status.loading');
|
|
2357
|
+
try {
|
|
2358
|
+
const r = await apiFetch('/admin/config');
|
|
2359
|
+
if (!r.ok) { const d = await r.json(); throw new Error(d.error || 'Failed to load'); }
|
|
2360
|
+
configData = await r.json();
|
|
2361
|
+
showConfigTab(configActiveTab);
|
|
2362
|
+
document.getElementById('cfg-status').textContent = '';
|
|
2363
|
+
} catch (e) {
|
|
2364
|
+
document.getElementById('cfg-status').textContent = 'Error: ' + e.message;
|
|
2365
|
+
toast(t('toast.config_load_failed', { message: e.message }), 'error');
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
function showConfigTab(tab) {
|
|
2370
|
+
configActiveTab = tab;
|
|
2371
|
+
document.querySelectorAll('.cfg-tab').forEach(function(btn) { btn.classList.remove('active'); });
|
|
2372
|
+
const tabEl = document.getElementById('cfg-tab-' + tab);
|
|
2373
|
+
if (tabEl) tabEl.classList.add('active');
|
|
2374
|
+
|
|
2375
|
+
const def = CFG_TABS[tab] || CFG_TABS.all;
|
|
2376
|
+
const descKey = 'config.descriptions.' + tab;
|
|
2377
|
+
const desc = t(descKey);
|
|
2378
|
+
document.getElementById('cfg-section-desc').textContent = desc !== descKey ? desc : '';
|
|
2379
|
+
|
|
2380
|
+
const s3Note = document.getElementById('cfg-s3-note');
|
|
2381
|
+
if (def.s3) s3Note.classList.remove('hidden'); else s3Note.classList.add('hidden');
|
|
2382
|
+
|
|
2383
|
+
const formEl = document.getElementById('cfg-form');
|
|
2384
|
+
const editorEl = document.getElementById('cfg-editor');
|
|
2385
|
+
const jsonSection = document.getElementById('cfg-json-section');
|
|
2386
|
+
const jsonLabel = document.getElementById('cfg-json-label');
|
|
2387
|
+
const jsonHint = document.getElementById('cfg-json-hint');
|
|
2388
|
+
|
|
2389
|
+
if (CFG_FORM_TABS.has(tab)) {
|
|
2390
|
+
// Show form only — hide the JSON reference section
|
|
2391
|
+
formEl.classList.remove('hidden');
|
|
2392
|
+
formEl.innerHTML = renderFormTab(tab);
|
|
2393
|
+
jsonSection.classList.add('hidden');
|
|
2394
|
+
} else {
|
|
2395
|
+
// All tab: JSON only, fully editable
|
|
2396
|
+
formEl.classList.add('hidden');
|
|
2397
|
+
editorEl.value = JSON.stringify(configData || {}, null, 2);
|
|
2398
|
+
editorEl.readOnly = false;
|
|
2399
|
+
editorEl.style.opacity = '';
|
|
2400
|
+
jsonLabel.textContent = t('config.json_label_edit') || 'Full Configuration JSON';
|
|
2401
|
+
jsonHint.textContent = t('config.json_hint_edit') || '(edit directly — changes are saved on Save)';
|
|
2402
|
+
jsonSection.classList.remove('hidden');
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
async function saveConfig() {
|
|
2407
|
+
let sectionData;
|
|
2408
|
+
|
|
2409
|
+
if (CFG_FORM_TABS.has(configActiveTab)) {
|
|
2410
|
+
sectionData = collectFormData();
|
|
2411
|
+
if (sectionData === null) { toast(t('toast.form_read_error'), 'error'); return; }
|
|
2412
|
+
} else {
|
|
2413
|
+
const rawText = document.getElementById('cfg-editor').value.trim();
|
|
2414
|
+
try {
|
|
2415
|
+
sectionData = JSON.parse(rawText);
|
|
2416
|
+
} catch (e) {
|
|
2417
|
+
toast(t('toast.invalid_json', { message: e.message }), 'error');
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
if (typeof sectionData !== 'object' || Array.isArray(sectionData) || sectionData === null) {
|
|
2421
|
+
toast(t('toast.config_not_object'), 'error');
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Merge section into full config
|
|
2427
|
+
let fullConfig;
|
|
2428
|
+
if (configActiveTab === 'all') {
|
|
2429
|
+
fullConfig = sectionData;
|
|
2430
|
+
} else {
|
|
2431
|
+
fullConfig = Object.assign({}, configData || {});
|
|
2432
|
+
const tabKeys = CFG_TABS[configActiveTab].keys || [];
|
|
2433
|
+
for (const key of tabKeys) delete fullConfig[key];
|
|
2434
|
+
Object.assign(fullConfig, sectionData);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
const btn = document.getElementById('cfg-save-btn');
|
|
2438
|
+
btn.disabled = true; btn.textContent = t('config.saving_btn');
|
|
2439
|
+
document.getElementById('cfg-status').textContent = '';
|
|
2440
|
+
|
|
2441
|
+
try {
|
|
2442
|
+
const r = await apiFetch('/admin/config', {
|
|
2443
|
+
method: 'PUT',
|
|
2444
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2445
|
+
body: JSON.stringify(fullConfig),
|
|
2446
|
+
});
|
|
2447
|
+
const data = await r.json();
|
|
2448
|
+
if (!r.ok) throw new Error(data.error || 'Save failed');
|
|
2449
|
+
configData = fullConfig;
|
|
2450
|
+
// Refresh the JSON display to reflect the newly saved config
|
|
2451
|
+
const editorEl = document.getElementById('cfg-editor');
|
|
2452
|
+
if (editorEl) editorEl.value = JSON.stringify(configData, null, 2);
|
|
2453
|
+
|
|
2454
|
+
if (data.reloading) {
|
|
2455
|
+
document.getElementById('cfg-status').textContent = t('config.status.reloading');
|
|
2456
|
+
toast(t('toast.config_reloading'), 'info');
|
|
2457
|
+
setTimeout(async function() {
|
|
2458
|
+
try {
|
|
2459
|
+
schema = await fetch('/admin/schema').then(function(r2) { return r2.json(); });
|
|
2460
|
+
document.getElementById('project-name').textContent = schema.name;
|
|
2461
|
+
buildSidebar();
|
|
2462
|
+
document.getElementById('cfg-status').textContent = t('config.status.applied');
|
|
2463
|
+
toast(t('toast.config_applied'), 'success');
|
|
2464
|
+
} catch (ex) {
|
|
2465
|
+
document.getElementById('cfg-status').textContent = t('config.status.saved_reload');
|
|
2466
|
+
}
|
|
2467
|
+
}, 800);
|
|
2468
|
+
} else {
|
|
2469
|
+
document.getElementById('cfg-status').textContent = t('config.status.saved_restart');
|
|
2470
|
+
toast(t('toast.config_saved'), 'success');
|
|
2471
|
+
}
|
|
2472
|
+
} catch (e) {
|
|
2473
|
+
toast(t('toast.save_failed', { message: e.message }), 'error');
|
|
2474
|
+
} finally {
|
|
2475
|
+
btn.disabled = false; btn.textContent = t('config.save_btn');
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function resetConfigTab() {
|
|
2480
|
+
if (!configData) return;
|
|
2481
|
+
showConfigTab(configActiveTab);
|
|
2482
|
+
toast(t('toast.config_reset'), 'info');
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
// ── API Keys Panel ─────────────────────────────────────────────────────
|
|
2486
|
+
function selectApiKeys(el) {
|
|
2487
|
+
document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
|
|
2488
|
+
el.classList.add('active');
|
|
2489
|
+
configMode = false;
|
|
2490
|
+
currentItem = null;
|
|
2491
|
+
document.getElementById('page-title').textContent = 'API Keys';
|
|
2492
|
+
document.getElementById('btn-add').classList.add('hidden');
|
|
2493
|
+
document.getElementById('btn-share').classList.add('hidden');
|
|
2494
|
+
document.getElementById('btn-devs').classList.add('hidden');
|
|
2495
|
+
showOnlyArea('api-keys-area');
|
|
2496
|
+
closeSidebar();
|
|
2497
|
+
loadApiKeys();
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
async function loadApiKeys() {
|
|
2501
|
+
const listEl = document.getElementById('api-keys-list');
|
|
2502
|
+
listEl.innerHTML = '<div class="flex justify-center py-8"><div class="w-5 h-5 border-2 border-cs-border border-t-cs-primary rounded-full animate-spin"></div></div>';
|
|
2503
|
+
try {
|
|
2504
|
+
const r = await apiFetch('/admin/api-keys');
|
|
2505
|
+
if (!r.ok) throw new Error('Failed to load API keys');
|
|
2506
|
+
const keys = await r.json();
|
|
2507
|
+
listEl.innerHTML = renderApiKeysList(keys);
|
|
2508
|
+
} catch (e) {
|
|
2509
|
+
listEl.innerHTML = '<p class="text-red-400 text-sm p-4">' + escHtml(e.message) + '</p>';
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function renderApiKeysList(keys) {
|
|
2514
|
+
if (!keys.length) {
|
|
2515
|
+
return '<div class="flex flex-col items-center justify-center py-16 text-center"><p class="text-sm" style="color:var(--color-cs-muted);">No API keys yet. Click <span style="color:var(--color-cs-text);">+ Create API Key</span> to add one.</p></div>';
|
|
2516
|
+
}
|
|
2517
|
+
const rows = keys.map(function(k) {
|
|
2518
|
+
const perms = (k.permissions && k.permissions.length) ? k.permissions.join(', ') : 'all';
|
|
2519
|
+
const ents = (k.entities && k.entities.length) ? k.entities.join(', ') : 'all';
|
|
2520
|
+
const exp = k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : '—';
|
|
2521
|
+
return '<tr class="border-b" style="border-color:var(--color-cs-border);">'
|
|
2522
|
+
+ '<td class="px-4 py-2.5 text-sm" style="color:var(--color-cs-text);">' + escHtml(k.name) + '</td>'
|
|
2523
|
+
+ '<td class="px-4 py-2.5 text-xs" style="color:var(--color-cs-muted);">' + escHtml(k.userEntity) + '</td>'
|
|
2524
|
+
+ '<td class="px-4 py-2.5 text-xs" style="color:var(--color-cs-muted);" title="' + escHtml(k.userId) + '">' + escHtml(k.userId.substring(0, 8)) + '…</td>'
|
|
2525
|
+
+ '<td class="px-4 py-2.5 text-xs" style="color:var(--color-cs-muted);">' + escHtml(perms) + '</td>'
|
|
2526
|
+
+ '<td class="px-4 py-2.5 text-xs" style="color:var(--color-cs-muted);">' + escHtml(ents) + '</td>'
|
|
2527
|
+
+ '<td class="px-4 py-2.5 text-xs" style="color:var(--color-cs-muted);">' + escHtml(exp) + '</td>'
|
|
2528
|
+
+ '<td class="px-4 py-2.5 text-xs" style="color:var(--color-cs-muted);">' + escHtml(k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : '—') + '</td>'
|
|
2529
|
+
+ '<td class="px-4 py-2.5"><button class="text-xs border rounded px-2 py-1 hover:opacity-80" style="border-color:rgba(239,68,68,0.4);color:#f87171;background:transparent;" onclick="deleteApiKeyAdmin(' + "'" + escHtml(k.id) + "'" + ')">Delete</button></td>'
|
|
2530
|
+
+ '</tr>';
|
|
2531
|
+
}).join('');
|
|
2532
|
+
return '<div class="overflow-x-auto border rounded" style="border-color:var(--color-cs-border);">'
|
|
2533
|
+
+ '<table class="w-full text-sm" style="color:var(--color-cs-text);">'
|
|
2534
|
+
+ '<thead style="background:var(--color-cs-surface);"><tr class="border-b" style="border-color:var(--color-cs-border);">'
|
|
2535
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">Name</th>'
|
|
2536
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">Collection</th>'
|
|
2537
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">User ID</th>'
|
|
2538
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">Permissions</th>'
|
|
2539
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">Entities</th>'
|
|
2540
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">Expires</th>'
|
|
2541
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">Last Used</th>'
|
|
2542
|
+
+ '<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:var(--color-cs-muted);">Actions</th>'
|
|
2543
|
+
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
async function deleteApiKeyAdmin(id) {
|
|
2547
|
+
if (!confirm('Delete this API key? This cannot be undone.')) return;
|
|
2548
|
+
try {
|
|
2549
|
+
const r = await apiFetch('/admin/api-keys/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
2550
|
+
if (!r.ok) { const d = await r.json(); throw new Error(d.error || 'Delete failed'); }
|
|
2551
|
+
toast('API key deleted', 'success');
|
|
2552
|
+
loadApiKeys();
|
|
2553
|
+
} catch (e) { toast('Delete failed: ' + e.message, 'error'); }
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// ── Create API Key modal ─────────────────────────────────────────────────
|
|
2557
|
+
function openCreateApiKeyModal(prefillUserId, prefillEntity) {
|
|
2558
|
+
document.getElementById('apikey-form').classList.remove('hidden');
|
|
2559
|
+
document.getElementById('apikey-result').classList.add('hidden');
|
|
2560
|
+
document.getElementById('apikey-form-error').classList.add('hidden');
|
|
2561
|
+
document.getElementById('apikey-name').value = '';
|
|
2562
|
+
document.getElementById('apikey-expires').value = '';
|
|
2563
|
+
document.getElementById('apikey-user-id').value = prefillUserId || '';
|
|
2564
|
+
document.querySelectorAll('.apikey-perm').forEach(function(cb) { cb.checked = false; });
|
|
2565
|
+
|
|
2566
|
+
// Populate entity access checkboxes
|
|
2567
|
+
const entList = document.getElementById('apikey-entities-list');
|
|
2568
|
+
entList.innerHTML = getNonAuthEntities().map(function(e) {
|
|
2569
|
+
return '<label class="flex items-center gap-1.5 text-xs cursor-pointer select-none" style="color:var(--color-cs-muted);">'
|
|
2570
|
+
+ '<input type="checkbox" class="apikey-entity-cb" value="' + escAttr(e.slug) + '" /> ' + escHtml(e.name) + '</label>';
|
|
2571
|
+
}).join('');
|
|
2572
|
+
|
|
2573
|
+
// Populate user entity selector for admin (create key for another user)
|
|
2574
|
+
const sel = document.getElementById('apikey-user-entity');
|
|
2575
|
+
sel.innerHTML = '<option value="">— Create for myself —</option>'
|
|
2576
|
+
+ (schema.userCollections || []).map(function(uc) {
|
|
2577
|
+
return '<option value="' + escAttr(uc.name) + '"' + (prefillEntity === uc.name ? ' selected' : '') + '>' + escHtml(uc.name) + '</option>';
|
|
2578
|
+
}).join('');
|
|
2579
|
+
sel.onchange = function() {
|
|
2580
|
+
const row = document.getElementById('apikey-user-id-row');
|
|
2581
|
+
row.classList.toggle('hidden', !sel.value);
|
|
2582
|
+
};
|
|
2583
|
+
if (prefillEntity) {
|
|
2584
|
+
sel.value = prefillEntity;
|
|
2585
|
+
document.getElementById('apikey-user-id-row').classList.remove('hidden');
|
|
2586
|
+
} else {
|
|
2587
|
+
document.getElementById('apikey-user-id-row').classList.add('hidden');
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
document.getElementById('apikey-modal-backdrop').classList.remove('hidden');
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function closeApiKeyModal() {
|
|
2594
|
+
document.getElementById('apikey-modal-backdrop').classList.add('hidden');
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
async function submitCreateApiKey() {
|
|
2598
|
+
const name = document.getElementById('apikey-name').value.trim() || 'API Key';
|
|
2599
|
+
const expiresAt = document.getElementById('apikey-expires').value;
|
|
2600
|
+
const permissions = Array.from(document.querySelectorAll('.apikey-perm:checked')).map(function(cb) { return cb.value; });
|
|
2601
|
+
const entities = Array.from(document.querySelectorAll('.apikey-entity-cb:checked')).map(function(cb) { return cb.value; });
|
|
2602
|
+
const selEntity = document.getElementById('apikey-user-entity').value;
|
|
2603
|
+
const selUserId = document.getElementById('apikey-user-id').value.trim();
|
|
2604
|
+
|
|
2605
|
+
const errEl = document.getElementById('apikey-form-error');
|
|
2606
|
+
errEl.classList.add('hidden');
|
|
2607
|
+
|
|
2608
|
+
const btn = document.getElementById('apikey-submit-btn');
|
|
2609
|
+
btn.disabled = true; btn.textContent = 'Creating…';
|
|
2610
|
+
|
|
2611
|
+
try {
|
|
2612
|
+
let r, data;
|
|
2613
|
+
if (selEntity && selUserId) {
|
|
2614
|
+
// Admin creates key for another user
|
|
2615
|
+
r = await apiFetch('/admin/api-keys', {
|
|
2616
|
+
method: 'POST',
|
|
2617
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2618
|
+
body: JSON.stringify({ userId: selUserId, userEntity: selEntity, name, permissions, entities, expiresAt: expiresAt || null }),
|
|
2619
|
+
});
|
|
2620
|
+
} else {
|
|
2621
|
+
// User creates key for themselves
|
|
2622
|
+
const col = getAdminCollections()[0];
|
|
2623
|
+
if (!col) throw new Error('No admin collection found');
|
|
2624
|
+
r = await apiFetch('/api/auth/' + toSlug(col.name) + '/api-keys', {
|
|
2625
|
+
method: 'POST',
|
|
2626
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2627
|
+
body: JSON.stringify({ name, permissions, entities, expiresAt: expiresAt || null }),
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
data = await r.json();
|
|
2631
|
+
if (!r.ok) throw new Error(data.error || 'Failed to create key');
|
|
2632
|
+
document.getElementById('apikey-result-value').value = data.key;
|
|
2633
|
+
document.getElementById('apikey-form').classList.add('hidden');
|
|
2634
|
+
document.getElementById('apikey-result').classList.remove('hidden');
|
|
2635
|
+
// Refresh list if on API keys panel
|
|
2636
|
+
if (!document.getElementById('api-keys-area').classList.contains('hidden')) loadApiKeys();
|
|
2637
|
+
} catch (e) {
|
|
2638
|
+
errEl.textContent = e.message;
|
|
2639
|
+
errEl.classList.remove('hidden');
|
|
2640
|
+
} finally {
|
|
2641
|
+
btn.disabled = false; btn.textContent = 'Create Key';
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function copyApiKey() {
|
|
2646
|
+
const val = document.getElementById('apikey-result-value').value;
|
|
2647
|
+
if (navigator.clipboard) { navigator.clipboard.writeText(val).then(function() { toast('Copied!', 'success'); }); }
|
|
2648
|
+
else { document.getElementById('apikey-result-value').select(); document.execCommand('copy'); toast('Copied!', 'success'); }
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// ── Impersonate ───────────────────────────────────────────────────────────
|
|
2652
|
+
async function impersonateUser(userId, entityName) {
|
|
2653
|
+
try {
|
|
2654
|
+
const r = await apiFetch('/admin/impersonate', {
|
|
2655
|
+
method: 'POST',
|
|
2656
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2657
|
+
body: JSON.stringify({ userId, userEntity: entityName }),
|
|
2658
|
+
});
|
|
2659
|
+
const data = await r.json();
|
|
2660
|
+
if (!r.ok) throw new Error(data.error || 'Impersonate failed');
|
|
2661
|
+
document.getElementById('impersonate-token').value = data.token;
|
|
2662
|
+
document.getElementById('impersonate-user-info').textContent =
|
|
2663
|
+
'User: ' + (data.user && data.user.email ? data.user.email : userId) + ' (' + entityName + ')' +
|
|
2664
|
+
' · Expires: ' + new Date(data.expiresAt).toLocaleTimeString();
|
|
2665
|
+
document.getElementById('impersonate-modal-backdrop').classList.remove('hidden');
|
|
2666
|
+
} catch (e) { toast('Impersonate failed: ' + e.message, 'error'); }
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function closeImpersonateModal() {
|
|
2670
|
+
document.getElementById('impersonate-modal-backdrop').classList.add('hidden');
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
function copyImpersonateToken() {
|
|
2674
|
+
const val = document.getElementById('impersonate-token').value;
|
|
2675
|
+
if (navigator.clipboard) { navigator.clipboard.writeText(val).then(function() { toast('Copied!', 'success'); }); }
|
|
2676
|
+
else { document.getElementById('impersonate-token').select(); document.execCommand('copy'); toast('Copied!', 'success'); }
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
// ── AI Chat ────────────────────────────────────────────────────────────
|
|
2680
|
+
let aiMessages = []; // { role: 'user'|'assistant', content: string }
|
|
2681
|
+
let aiPanelOpen = false;
|
|
2682
|
+
|
|
2683
|
+
async function checkAiStatus() {
|
|
2684
|
+
try {
|
|
2685
|
+
const r = await fetch('/admin/ai/status');
|
|
2686
|
+
if (!r.ok) return;
|
|
2687
|
+
const data = await r.json();
|
|
2688
|
+
if (data.configured) {
|
|
2689
|
+
const btn = document.getElementById('btn-ai');
|
|
2690
|
+
btn.classList.remove('hidden');
|
|
2691
|
+
btn.classList.add('flex');
|
|
2692
|
+
}
|
|
2693
|
+
} catch (e) { console.warn('AI status check failed:', e); }
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
function toggleAiPanel() {
|
|
2697
|
+
if (aiPanelOpen) closeAiPanel(); else openAiPanel();
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
function openAiPanel() {
|
|
2701
|
+
aiPanelOpen = true;
|
|
2702
|
+
document.getElementById('ai-panel').style.translate = '0';
|
|
2703
|
+
if (aiMessages.length === 0) renderAiWelcome();
|
|
2704
|
+
setTimeout(() => document.getElementById('ai-input').focus(), 220);
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
function closeAiPanel() {
|
|
2708
|
+
aiPanelOpen = false;
|
|
2709
|
+
document.getElementById('ai-panel').style.translate = '100%';
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function clearAiChat() {
|
|
2713
|
+
aiMessages = [];
|
|
2714
|
+
document.getElementById('ai-messages').innerHTML = '';
|
|
2715
|
+
renderAiWelcome();
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
function renderAiWelcome() {
|
|
2719
|
+
const el = document.getElementById('ai-messages');
|
|
2720
|
+
el.innerHTML = '<div class="text-xs text-center py-6" style="color:var(--color-cs-muted);">Ask me anything about your data, API routes, entity config, or how ChadStart works.</div>';
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
function appendAiMessage(role, content) {
|
|
2724
|
+
const container = document.getElementById('ai-messages');
|
|
2725
|
+
// Remove welcome message if present
|
|
2726
|
+
const welcome = container.querySelector('.text-center');
|
|
2727
|
+
if (welcome) welcome.remove();
|
|
2728
|
+
|
|
2729
|
+
const isUser = role === 'user';
|
|
2730
|
+
const bubble = document.createElement('div');
|
|
2731
|
+
bubble.className = 'flex ' + (isUser ? 'justify-end' : 'justify-start');
|
|
2732
|
+
const inner = document.createElement('div');
|
|
2733
|
+
inner.className = 'max-w-[85%] rounded px-3 py-2 text-sm';
|
|
2734
|
+
inner.style.cssText = isUser
|
|
2735
|
+
? 'background:rgba(187,134,252,0.15);color:var(--color-cs-text);border:1px solid rgba(187,134,252,0.25);'
|
|
2736
|
+
: 'background:var(--color-cs-surface);color:var(--color-cs-text);border:1px solid var(--color-cs-border);white-space:pre-wrap;';
|
|
2737
|
+
inner.textContent = content;
|
|
2738
|
+
bubble.appendChild(inner);
|
|
2739
|
+
container.appendChild(bubble);
|
|
2740
|
+
container.scrollTop = container.scrollHeight;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function appendAiThinking() {
|
|
2744
|
+
const container = document.getElementById('ai-messages');
|
|
2745
|
+
const bubble = document.createElement('div');
|
|
2746
|
+
bubble.id = 'ai-thinking';
|
|
2747
|
+
bubble.className = 'flex justify-start';
|
|
2748
|
+
bubble.innerHTML = '<div class="rounded px-3 py-2 text-sm" style="background:var(--color-cs-surface);color:var(--color-cs-muted);border:1px solid var(--color-cs-border);">Thinking…</div>';
|
|
2749
|
+
container.appendChild(bubble);
|
|
2750
|
+
container.scrollTop = container.scrollHeight;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function removeAiThinking() {
|
|
2754
|
+
const el = document.getElementById('ai-thinking');
|
|
2755
|
+
if (el) el.remove();
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function aiInputKeydown(e) {
|
|
2759
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendAiMessage(); }
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
async function sendAiMessage() {
|
|
2763
|
+
const inputEl = document.getElementById('ai-input');
|
|
2764
|
+
const sendBtn = document.getElementById('ai-send-btn');
|
|
2765
|
+
const text = inputEl.value.trim();
|
|
2766
|
+
if (!text) return;
|
|
2767
|
+
|
|
2768
|
+
inputEl.value = '';
|
|
2769
|
+
inputEl.disabled = true;
|
|
2770
|
+
sendBtn.disabled = true;
|
|
2771
|
+
|
|
2772
|
+
aiMessages.push({ role: 'user', content: text });
|
|
2773
|
+
appendAiMessage('user', text);
|
|
2774
|
+
appendAiThinking();
|
|
2775
|
+
|
|
2776
|
+
try {
|
|
2777
|
+
const r = await apiFetch('/admin/ai/chat', {
|
|
2778
|
+
method: 'POST',
|
|
2779
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2780
|
+
body: JSON.stringify({ messages: aiMessages }),
|
|
2781
|
+
});
|
|
2782
|
+
const data = await r.json();
|
|
2783
|
+
removeAiThinking();
|
|
2784
|
+
if (!r.ok) throw new Error(data.error || 'AI request failed');
|
|
2785
|
+
const reply = data.message;
|
|
2786
|
+
aiMessages.push({ role: 'assistant', content: reply });
|
|
2787
|
+
appendAiMessage('assistant', reply);
|
|
2788
|
+
} catch (err) {
|
|
2789
|
+
removeAiThinking();
|
|
2790
|
+
appendAiMessage('assistant', '⚠ ' + err.message);
|
|
2791
|
+
// Don't push error into history — let the user retry
|
|
2792
|
+
aiMessages.pop();
|
|
2793
|
+
} finally {
|
|
2794
|
+
inputEl.disabled = false;
|
|
2795
|
+
sendBtn.disabled = false;
|
|
2796
|
+
inputEl.focus();
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
</script>
|
|
2801
|
+
</body>
|
|
2802
|
+
</html>
|