sirius-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. sirius_cli/__init__.py +1 -0
  2. sirius_cli/cli.py +335 -0
  3. sirius_cli/generator.py +125 -0
  4. sirius_cli/parser.py +322 -0
  5. sirius_cli/templates/backend/Dockerfile.jinja2 +17 -0
  6. sirius_cli/templates/backend/alembic/env.py.jinja2 +70 -0
  7. sirius_cli/templates/backend/alembic/script.py.mako.jinja2 +26 -0
  8. sirius_cli/templates/backend/database.py.jinja2 +39 -0
  9. sirius_cli/templates/backend/main.py.jinja2 +178 -0
  10. sirius_cli/templates/backend/models.py.jinja2 +31 -0
  11. sirius_cli/templates/backend/requirements.txt.jinja2 +13 -0
  12. sirius_cli/templates/backend/schemas.py.jinja2 +80 -0
  13. sirius_cli/templates/docker-compose.yml.jinja2 +27 -0
  14. sirius_cli/templates/frontend/.env.jinja2 +3 -0
  15. sirius_cli/templates/frontend/Dockerfile.jinja2 +12 -0
  16. sirius_cli/templates/frontend/index.html.jinja2 +16 -0
  17. sirius_cli/templates/frontend/package.json.jinja2 +28 -0
  18. sirius_cli/templates/frontend/postcss.config.js.jinja2 +6 -0
  19. sirius_cli/templates/frontend/src/App.tsx.jinja2 +89 -0
  20. sirius_cli/templates/frontend/src/Dashboard.tsx.jinja2 +210 -0
  21. sirius_cli/templates/frontend/src/TableCrud.tsx.jinja2 +567 -0
  22. sirius_cli/templates/frontend/src/index.css.jinja2 +25 -0
  23. sirius_cli/templates/frontend/src/main.tsx.jinja2 +10 -0
  24. sirius_cli/templates/frontend/tailwind.config.js.jinja2 +21 -0
  25. sirius_cli/templates/frontend/tsconfig.json.jinja2 +25 -0
  26. sirius_cli/templates/frontend/vite.config.ts.jinja2 +10 -0
  27. sirius_cli-0.1.0.dist-info/METADATA +165 -0
  28. sirius_cli-0.1.0.dist-info/RECORD +32 -0
  29. sirius_cli-0.1.0.dist-info/WHEEL +5 -0
  30. sirius_cli-0.1.0.dist-info/entry_points.txt +2 -0
  31. sirius_cli-0.1.0.dist-info/licenses/LICENSE +619 -0
  32. sirius_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,567 @@
1
+ import React, { useEffect, useState, useCallback } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { Plus, Edit2, Trash2, X, ChevronLeft, ChevronRight, Loader2, Search, Download, ArrowUpDown, ArrowUp, ArrowDown, FileSpreadsheet } from 'lucide-react'
4
+
5
+ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000'
6
+
7
+ interface RecordType {
8
+ id: number
9
+ [key: string]: any
10
+ }
11
+
12
+ export default function {{ table_name.replace('_', ' ').title().replace(' ', '') }}Crud() {
13
+ const navigate = useNavigate()
14
+ const [records, setRecords] = useState<RecordType[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [submitting, setSubmitting] = useState(false)
17
+ const [error, setError] = useState<string | null>(null)
18
+
19
+ // Pagination states
20
+ const [page, setPage] = useState(1)
21
+ const limit = 10
22
+ const offset = (page - 1) * limit
23
+
24
+ // Server-side search
25
+ const [search, setSearch] = useState('')
26
+ const [debouncedSearch, setDebouncedSearch] = useState('')
27
+
28
+ // Sort states
29
+ const [sortCol, setSortCol] = useState<string | null>(null)
30
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
31
+
32
+ // Modal states
33
+ const [isModalOpen, setIsModalOpen] = useState(false)
34
+ const [editingRecord, setEditingRecord] = useState<RecordType | null>(null)
35
+
36
+ // Options for foreign keys
37
+ {% for col in columns %}
38
+ {% if col.foreign_key %}
39
+ {% set target_table = col.foreign_key.split('.')[0] %}
40
+ const [{{ target_table }}Options, set{{ target_table.title().replace('_', '').replace(' ', '') }}Options] = useState<any[]>([])
41
+ const [{{ target_table }}OptionsError, set{{ target_table.title().replace('_', '').replace(' ', '') }}OptionsError] = useState<string | null>(null)
42
+ {% endif %}
43
+ {% endfor %}
44
+
45
+ // Form states
46
+ const [formData, setFormData] = useState<Record<string, any>>({
47
+ {% for col in columns %}
48
+ {% if not col.is_pk %}
49
+ {% if col.type == 'Boolean' %}
50
+ {{ col.name }}: false,
51
+ {% else %}
52
+ {{ col.name }}: '',
53
+ {% endif %}
54
+ {% endif %}
55
+ {% endfor %}
56
+ })
57
+
58
+ const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
59
+
60
+ // Debounce search input
61
+ useEffect(() => {
62
+ const timer = setTimeout(() => {
63
+ setDebouncedSearch(search)
64
+ setPage(1)
65
+ }, 350)
66
+ return () => clearTimeout(timer)
67
+ }, [search])
68
+
69
+ const fetchRecords = useCallback(async () => {
70
+ try {
71
+ setLoading(true)
72
+ const params = new URLSearchParams({
73
+ limit: String(limit + 1),
74
+ offset: String(offset),
75
+ })
76
+ if (debouncedSearch) params.set('search', debouncedSearch)
77
+ if (sortCol) {
78
+ params.set('order_by', sortCol)
79
+ params.set('dir', sortDir)
80
+ }
81
+ const res = await fetch(`${API_BASE}/api/{{ table_name }}?${params}`)
82
+ if (!res.ok) throw new Error('Failed to load data from server')
83
+ const data = await res.json()
84
+ setRecords(data)
85
+ setError(null)
86
+ } catch (err: any) {
87
+ setError(err.message || 'Something went wrong')
88
+ } finally {
89
+ setLoading(false)
90
+ }
91
+ }, [page, debouncedSearch, sortCol, sortDir])
92
+
93
+ useEffect(() => {
94
+ fetchRecords()
95
+ }, [fetchRecords])
96
+
97
+ // Fetch Foreign Key Options
98
+ useEffect(() => {
99
+ {% for col in columns %}
100
+ {% if col.foreign_key %}
101
+ {% set target_table = col.foreign_key.split('.')[0] %}
102
+ fetch(`${API_BASE}/api/{{ target_table }}?limit=500`)
103
+ .then(res => {
104
+ if (!res.ok) throw new Error(`Server responded ${res.status}`)
105
+ return res.json()
106
+ })
107
+ .then(data => set{{ target_table.title().replace('_', '').replace(' ', '') }}Options(data))
108
+ .catch(err => {
109
+ console.error("Failed to load options for {{ target_table }}", err)
110
+ set{{ target_table.title().replace('_', '').replace(' ', '') }}OptionsError(err.message || 'Failed to load options')
111
+ })
112
+ {% endif %}
113
+ {% endfor %}
114
+ }, [])
115
+
116
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
117
+ const { name, type } = e.target
118
+ if (type === 'checkbox') {
119
+ const checked = (e.target as HTMLInputElement).checked
120
+ setFormData(prev => ({ ...prev, [name]: checked }))
121
+ } else {
122
+ const value = e.target.value
123
+ setFormData(prev => ({ ...prev, [name]: value }))
124
+ }
125
+ }
126
+
127
+ const openCreateModal = () => {
128
+ setEditingRecord(null)
129
+ setFormData({
130
+ {% for col in columns %}
131
+ {% if not col.is_pk %}
132
+ {% if col.type == 'Boolean' %}
133
+ {{ col.name }}: false,
134
+ {% else %}
135
+ {{ col.name }}: '',
136
+ {% endif %}
137
+ {% endif %}
138
+ {% endfor %}
139
+ })
140
+ setIsModalOpen(true)
141
+ }
142
+
143
+ const openEditModal = (record: RecordType) => {
144
+ setEditingRecord(record)
145
+ const mappedData = { ...record }
146
+ {% for col in columns %}
147
+ {% if col.type == 'DateTime' %}
148
+ if (record.{{ col.name }}) {
149
+ mappedData.{{ col.name }} = new Date(record.{{ col.name }}).toISOString().slice(0, 16)
150
+ }
151
+ {% endif %}
152
+ {% endfor %}
153
+ setFormData(mappedData)
154
+ setIsModalOpen(true)
155
+ }
156
+
157
+ const handleSubmit = async (e: React.FormEvent) => {
158
+ e.preventDefault()
159
+ setSubmitting(true)
160
+ try {
161
+ const url = editingRecord
162
+ ? `${API_BASE}/api/{{ table_name }}/${editingRecord.id}`
163
+ : `${API_BASE}/api/{{ table_name }}`
164
+ const method = editingRecord ? 'PUT' : 'POST'
165
+
166
+ const payload: Record<string, any> = {}
167
+ {% for col in columns %}
168
+ {% if not col.is_pk %}
169
+ {% if col.type == 'Integer' %}
170
+ payload.{{ col.name }} = formData.{{ col.name }} !== '' && formData.{{ col.name }} !== null ? parseInt(formData.{{ col.name }}, 10) : null
171
+ {% elif col.type == 'Float' %}
172
+ payload.{{ col.name }} = formData.{{ col.name }} !== '' && formData.{{ col.name }} !== null ? parseFloat(formData.{{ col.name }}) : null
173
+ {% elif col.type == 'Boolean' %}
174
+ payload.{{ col.name }} = !!formData.{{ col.name }}
175
+ {% elif col.type == 'DateTime' %}
176
+ payload.{{ col.name }} = formData.{{ col.name }} !== '' && formData.{{ col.name }} !== null ? new Date(formData.{{ col.name }}).toISOString() : null
177
+ {% else %}
178
+ payload.{{ col.name }} = formData.{{ col.name }} || null
179
+ {% endif %}
180
+ {% endif %}
181
+ {% endfor %}
182
+
183
+ const res = await fetch(url, {
184
+ method,
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify(payload)
187
+ })
188
+
189
+ if (!res.ok) throw new Error('Failed to save record')
190
+
191
+ setIsModalOpen(false)
192
+ fetchRecords()
193
+ } catch (err: any) {
194
+ alert(err.message || 'Error saving record')
195
+ } finally {
196
+ setSubmitting(false)
197
+ }
198
+ }
199
+
200
+ const handleDelete = async (id: number) => {
201
+ try {
202
+ const res = await fetch(`${API_BASE}/api/{{ table_name }}/${id}`, {
203
+ method: 'DELETE'
204
+ })
205
+ if (!res.ok) throw new Error('Failed to delete record')
206
+ setDeleteConfirmId(null)
207
+ fetchRecords()
208
+ } catch (err: any) {
209
+ alert(err.message || 'Error deleting record')
210
+ }
211
+ }
212
+
213
+ const handleExport = (fmt: 'csv' | 'xlsx') => {
214
+ window.open(`${API_BASE}/api/{{ table_name }}/export?fmt=${fmt}`, '_blank')
215
+ }
216
+
217
+ const handleSort = (colName: string) => {
218
+ if (sortCol === colName) {
219
+ setSortDir(prev => prev === 'asc' ? 'desc' : 'asc')
220
+ } else {
221
+ setSortCol(colName)
222
+ setSortDir('asc')
223
+ }
224
+ }
225
+
226
+ const hasNextPage = records.length > limit
227
+ const displayedRecords = hasNextPage ? records.slice(0, limit) : records
228
+
229
+ const SortIcon = ({ col }: { col: string }) => {
230
+ if (sortCol !== col) return <ArrowUpDown className="w-3 h-3 text-slate-600" />
231
+ return sortDir === 'asc' ? <ArrowUp className="w-3 h-3 text-brand-400" /> : <ArrowDown className="w-3 h-3 text-brand-400" />
232
+ }
233
+
234
+ return (
235
+ <div className="space-y-6">
236
+ {/* Header Panel */}
237
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
238
+ <div>
239
+ <h2 className="text-2xl font-bold font-outfit text-white capitalize">
240
+ {{ table_name.replace('_', ' ') }}
241
+ </h2>
242
+ <p className="text-slate-400 text-xs mt-1 font-sans">
243
+ Create, update, and manage entries for {{ table_name }}.
244
+ </p>
245
+ </div>
246
+
247
+ <div className="flex gap-2">
248
+ <button
249
+ onClick={() => handleExport('csv')}
250
+ className="flex items-center gap-2 px-4 py-2.5 bg-slate-900 border border-slate-805 hover:bg-slate-800 rounded-xl text-xs font-semibold text-slate-300 transition-all"
251
+ >
252
+ <Download className="w-4 h-4 text-slate-400" />
253
+ CSV
254
+ </button>
255
+ <button
256
+ onClick={() => handleExport('xlsx')}
257
+ className="flex items-center gap-2 px-4 py-2.5 bg-slate-900 border border-slate-805 hover:bg-slate-800 rounded-xl text-xs font-semibold text-slate-300 transition-all"
258
+ >
259
+ <FileSpreadsheet className="w-4 h-4 text-emerald-400" />
260
+ Excel
261
+ </button>
262
+ <button
263
+ onClick={openCreateModal}
264
+ className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-brand-600 to-brand-700 hover:from-brand-500 hover:to-brand-600 rounded-xl text-xs font-semibold shadow-lg shadow-brand-500/20 text-white transition-all"
265
+ >
266
+ <Plus className="w-4 h-4" />
267
+ Add Record
268
+ </button>
269
+ </div>
270
+ </div>
271
+
272
+ {/* Search & Stats Bar */}
273
+ <div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-slate-900/30 p-4 border border-slate-900 rounded-xl">
274
+ <div className="relative w-full sm:w-85">
275
+ <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
276
+ <input
277
+ type="text"
278
+ placeholder="Search all records..."
279
+ value={search}
280
+ onChange={(e) => setSearch(e.target.value)}
281
+ className="w-full pl-10 pr-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs focus:border-brand-500 focus:outline-none text-slate-300 transition-colors"
282
+ />
283
+ </div>
284
+ <div className="text-xs text-slate-500 font-medium flex items-center gap-2">
285
+ {debouncedSearch && (
286
+ <span className="px-2 py-0.5 rounded-md bg-brand-500/10 border border-brand-500/20 text-brand-400 text-[10px] font-semibold">
287
+ Server search
288
+ </span>
289
+ )}
290
+ Showing {displayedRecords.length} record{displayedRecords.length !== 1 ? 's' : ''} on this page
291
+ </div>
292
+ </div>
293
+
294
+ {/* Main Grid/Table */}
295
+ {loading ? (
296
+ <div className="h-64 flex flex-col items-center justify-center bg-slate-900/10 border border-slate-900 rounded-2xl gap-3">
297
+ <Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
298
+ <span className="text-xs text-slate-500">Retrieving records from backend...</span>
299
+ </div>
300
+ ) : error ? (
301
+ <div className="p-6 border border-red-500/20 bg-red-500/5 text-red-400 rounded-2xl text-center">
302
+ <p className="font-semibold text-sm">Failed to connect to backend server</p>
303
+ <p className="text-xs mt-1 text-red-400/80">Make sure your FastAPI server is running.</p>
304
+ </div>
305
+ ) : displayedRecords.length === 0 ? (
306
+ <div className="p-12 border border-dashed border-slate-800 rounded-2xl text-center bg-slate-900/5">
307
+ <p className="text-slate-400 font-semibold text-sm">No records found</p>
308
+ <p className="text-xs mt-1 text-slate-500">Create a new record or adjust your search.</p>
309
+ <button
310
+ onClick={openCreateModal}
311
+ className="mt-4 px-4 py-2 bg-slate-900 hover:bg-slate-800 border border-slate-850 text-xs font-semibold text-slate-300 rounded-xl transition-all"
312
+ >
313
+ Add Record
314
+ </button>
315
+ </div>
316
+ ) : (
317
+ <div className="bg-slate-900/20 border border-slate-900 rounded-2xl overflow-hidden">
318
+ <div className="overflow-x-auto">
319
+ <table className="w-full text-left border-collapse">
320
+ <thead>
321
+ <tr className="border-b border-slate-900 bg-slate-900/50">
322
+ {% for col in columns %}
323
+ <th
324
+ onClick={() => handleSort('{{ col.name }}')}
325
+ className="px-6 py-4 text-xs font-bold text-slate-400 uppercase font-outfit tracking-wider cursor-pointer hover:text-slate-200 select-none transition-colors"
326
+ >
327
+ <div className="flex items-center gap-1.5">
328
+ {{ col.name }}
329
+ <SortIcon col="{{ col.name }}" />
330
+ </div>
331
+ </th>
332
+ {% endfor %}
333
+ <th className="px-6 py-4 text-xs font-bold text-slate-400 uppercase font-outfit tracking-wider text-right">
334
+ Actions
335
+ </th>
336
+ </tr>
337
+ </thead>
338
+ <tbody className="divide-y divide-slate-900">
339
+ {displayedRecords.map((record) => (
340
+ <tr key={record.id} className="hover:bg-slate-900/30 transition-colors">
341
+ {% for col in columns %}
342
+ <td className="px-6 py-4 text-xs font-medium text-slate-300 font-mono">
343
+ {% if col.type == 'Boolean' %}
344
+ <span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${
345
+ record.{{ col.name }}
346
+ ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
347
+ : 'bg-slate-800 text-slate-400 border border-slate-700/50'
348
+ }`}>
349
+ {record.{{ col.name }} ? 'True' : 'False'}
350
+ </span>
351
+ {% elif col.type == 'DateTime' %}
352
+ {record.{{ col.name }} ? new Date(record.{{ col.name }}).toLocaleString() : '-'}
353
+ {% elif col.foreign_key %}
354
+ {% set target_table = col.foreign_key.split('.')[0] %}
355
+ <button
356
+ onClick={() => record.{{ col.name }} !== null && navigate('/{{ target_table }}')}
357
+ className="px-2 py-0.5 rounded bg-slate-800/80 text-brand-400 border border-slate-700/40 text-[10px] font-bold cursor-pointer hover:bg-slate-700/60 hover:border-brand-500/30 transition-all"
358
+ title="Navigate to {{ target_table }}"
359
+ >
360
+ {(() => {
361
+ const opt = {{ target_table }}Options.find((o: any) => o.id === record.{{ col.name }})
362
+ if (!opt) return record.{{ col.name }} !== null ? `{{ target_table }} #${record.{{ col.name }}}` : '-'
363
+ return opt.name || opt.username || opt.email || opt.title || opt.label || `{{ target_table }} #${opt.id}`
364
+ })()}
365
+ </button>
366
+ {% else %}
367
+ {record.{{ col.name }} !== null ? String(record.{{ col.name }}) : '-'}
368
+ {% endif %}
369
+ </td>
370
+ {% endfor %}
371
+ <td className="px-6 py-4 text-right">
372
+ <div className="flex items-center justify-end gap-2">
373
+ <button
374
+ onClick={() => openEditModal(record)}
375
+ className="p-1.5 hover:bg-slate-800 text-slate-400 hover:text-white rounded-lg transition-all"
376
+ >
377
+ <Edit2 className="w-3.5 h-3.5" />
378
+ </button>
379
+ {deleteConfirmId === record.id ? (
380
+ <div className="flex items-center gap-1.5">
381
+ <button
382
+ onClick={() => handleDelete(record.id)}
383
+ className="px-2 py-1 bg-red-600 hover:bg-red-500 text-[10px] text-white font-bold rounded-md"
384
+ >
385
+ Confirm
386
+ </button>
387
+ <button
388
+ onClick={() => setDeleteConfirmId(null)}
389
+ className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-[10px] text-slate-300 rounded-md"
390
+ >
391
+ Cancel
392
+ </button>
393
+ </div>
394
+ ) : (
395
+ <button
396
+ onClick={() => setDeleteConfirmId(record.id)}
397
+ className="p-1.5 hover:bg-red-950/40 text-slate-400 hover:text-red-400 rounded-lg transition-all"
398
+ >
399
+ <Trash2 className="w-3.5 h-3.5" />
400
+ </button>
401
+ )}
402
+ </div>
403
+ </td>
404
+ </tr>
405
+ ))}
406
+ </tbody>
407
+ </table>
408
+ </div>
409
+
410
+ {/* Pagination Footer */}
411
+ <div className="px-6 py-4 border-t border-slate-900 bg-slate-900/10 flex items-center justify-between">
412
+ <button
413
+ onClick={() => setPage(p => Math.max(1, p - 1))}
414
+ disabled={page === 1}
415
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 rounded-lg text-xs font-semibold text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
416
+ >
417
+ <ChevronLeft className="w-3.5 h-3.5" />
418
+ Prev
419
+ </button>
420
+ <span className="text-xs text-slate-500 font-medium">
421
+ Page {page}
422
+ </span>
423
+ <button
424
+ onClick={() => setPage(p => p + 1)}
425
+ disabled={!hasNextPage}
426
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 rounded-lg text-xs font-semibold text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
427
+ >
428
+ Next
429
+ <ChevronRight className="w-3.5 h-3.5" />
430
+ </button>
431
+ </div>
432
+ </div>
433
+ )}
434
+
435
+ {/* Create / Edit Modal */}
436
+ {isModalOpen && (
437
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
438
+ <div className="w-full max-w-lg bg-slate-900 border border-slate-850 rounded-2xl shadow-2xl flex flex-col max-h-[90vh]">
439
+ <header className="px-6 py-4 border-b border-slate-850 flex items-center justify-between">
440
+ <h3 className="font-outfit font-bold text-white text-base">
441
+ {editingRecord ? 'Update Record' : 'Create New Record'}
442
+ </h3>
443
+ <button
444
+ onClick={() => setIsModalOpen(false)}
445
+ className="text-slate-400 hover:text-white p-1 hover:bg-slate-800 rounded-lg transition-all"
446
+ >
447
+ <X className="w-4 h-4" />
448
+ </button>
449
+ </header>
450
+
451
+ <form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-4 flex-1">
452
+ {% for col in columns %}
453
+ {% if not col.is_pk %}
454
+ <div className="flex flex-col gap-1.5">
455
+ <label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-outfit">
456
+ {{ col.name.replace('_', ' ') }}
457
+ </label>
458
+
459
+ {% if col.foreign_key %}
460
+ {% set target_table = col.foreign_key.split('.')[0] %}
461
+ <select
462
+ name="{{ col.name }}"
463
+ value={formData.{{ col.name }} !== undefined && formData.{{ col.name }} !== null ? formData.{{ col.name }} : ''}
464
+ onChange={handleInputChange}
465
+ required
466
+ className="px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs focus:border-brand-500 focus:outline-none text-slate-200 transition-colors w-full"
467
+ >
468
+ <option value="">Select {{ target_table.replace('_', ' ') }}</option>
469
+ { {{ target_table }}OptionsError ? (
470
+ <option disabled>âš  Failed to load options</option>
471
+ ) : (
472
+ {{ target_table }}Options.map(opt => (
473
+ <option key={opt.id} value={opt.id}>
474
+ { opt.name || opt.username || opt.email || opt.title || opt.label || `ID: ${opt.id}` }
475
+ </option>
476
+ ))
477
+ )}
478
+ </select>
479
+
480
+ {% elif col.type == 'Boolean' %}
481
+ <div className="flex items-center gap-3 py-2">
482
+ <input
483
+ type="checkbox"
484
+ id="{{ col.name }}_field"
485
+ name="{{ col.name }}"
486
+ checked={formData.{{ col.name }} || false}
487
+ onChange={handleInputChange}
488
+ className="w-4.5 h-4.5 accent-brand-650 bg-slate-950 border border-slate-800 rounded focus:ring-0 focus:outline-none"
489
+ />
490
+ <label htmlFor="{{ col.name }}_field" className="text-xs text-slate-300">
491
+ Enable
492
+ </label>
493
+ </div>
494
+
495
+ {% elif col.type == 'Integer' %}
496
+ <input
497
+ type="number"
498
+ step="1"
499
+ min="0"
500
+ name="{{ col.name }}"
501
+ value={formData.{{ col.name }} !== undefined && formData.{{ col.name }} !== null ? formData.{{ col.name }} : ''}
502
+ onChange={handleInputChange}
503
+ required
504
+ className="px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs focus:border-brand-500 focus:outline-none text-slate-200 transition-colors"
505
+ />
506
+
507
+ {% elif col.type == 'Float' %}
508
+ <input
509
+ type="number"
510
+ step="any"
511
+ min="0"
512
+ name="{{ col.name }}"
513
+ value={formData.{{ col.name }} !== undefined && formData.{{ col.name }} !== null ? formData.{{ col.name }} : ''}
514
+ onChange={handleInputChange}
515
+ required
516
+ className="px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs focus:border-brand-500 focus:outline-none text-slate-200 transition-colors"
517
+ />
518
+
519
+ {% elif col.type == 'DateTime' %}
520
+ <input
521
+ type="datetime-local"
522
+ name="{{ col.name }}"
523
+ value={formData.{{ col.name }} || ''}
524
+ onChange={handleInputChange}
525
+ required
526
+ className="px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs focus:border-brand-500 focus:outline-none text-slate-200 transition-colors"
527
+ />
528
+
529
+ {% else %}
530
+ <input
531
+ type="{% if 'email' in col.name %}email{% else %}text{% endif %}"
532
+ name="{{ col.name }}"
533
+ value={formData.{{ col.name }} || ''}
534
+ onChange={handleInputChange}
535
+ required
536
+ className="px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs focus:border-brand-500 focus:outline-none text-slate-200 transition-colors"
537
+ />
538
+ {% endif %}
539
+ </div>
540
+ {% endif %}
541
+ {% endfor %}
542
+
543
+ <div className="pt-4 border-t border-slate-850 flex items-center justify-end gap-3">
544
+ <button
545
+ type="button"
546
+ onClick={() => setIsModalOpen(false)}
547
+ className="px-4 py-2 bg-slate-800 hover:bg-slate-750 text-slate-300 font-semibold text-xs rounded-xl transition-all"
548
+ >
549
+ Cancel
550
+ </button>
551
+ <button
552
+ type="submit"
553
+ disabled={submitting}
554
+ className="flex items-center gap-1.5 px-4 py-2 bg-brand-600 hover:bg-brand-500 text-white font-semibold text-xs rounded-xl disabled:opacity-50 transition-all shadow-lg shadow-brand-500/10"
555
+ >
556
+ {submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
557
+ Save
558
+ </button>
559
+ </div>
560
+ </form>
561
+ </div>
562
+ </div>
563
+ )}
564
+ </div>
565
+ )
566
+ }
567
+
@@ -0,0 +1,25 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ body {
7
+ @apply bg-slate-950 text-slate-100 selection:bg-cyan-500 selection:text-black;
8
+ }
9
+ }
10
+
11
+ /* Custom modern scrollbars */
12
+ ::-webkit-scrollbar {
13
+ width: 6px;
14
+ height: 6px;
15
+ }
16
+ ::-webkit-scrollbar-track {
17
+ background: #020617;
18
+ }
19
+ ::-webkit-scrollbar-thumb {
20
+ background: #1e293b;
21
+ border-radius: 9999px;
22
+ }
23
+ ::-webkit-scrollbar-thumb:hover {
24
+ background: #334155;
25
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.tsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ )
@@ -0,0 +1,21 @@
1
+ import colors from 'tailwindcss/colors'
2
+
3
+ /** @type {import('tailwindcss').Config} */
4
+ export default {
5
+ content: [
6
+ "./index.html",
7
+ "./src/**/*.{js,ts,jsx,tsx}",
8
+ ],
9
+ theme: {
10
+ extend: {
11
+ colors: {
12
+ brand: colors.{{ theme | default('blue') }}
13
+ },
14
+ fontFamily: {
15
+ sans: ['Inter', 'sans-serif'],
16
+ outfit: ['Outfit', 'sans-serif']
17
+ }
18
+ }
19
+ },
20
+ plugins: []
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noImplicitReturns": true,
22
+ "noFallthroughCasesInSwitch": true
23
+ },
24
+ "include": ["src"]
25
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ host: true
9
+ }
10
+ })