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.
- sirius_cli/__init__.py +1 -0
- sirius_cli/cli.py +335 -0
- sirius_cli/generator.py +125 -0
- sirius_cli/parser.py +322 -0
- sirius_cli/templates/backend/Dockerfile.jinja2 +17 -0
- sirius_cli/templates/backend/alembic/env.py.jinja2 +70 -0
- sirius_cli/templates/backend/alembic/script.py.mako.jinja2 +26 -0
- sirius_cli/templates/backend/database.py.jinja2 +39 -0
- sirius_cli/templates/backend/main.py.jinja2 +178 -0
- sirius_cli/templates/backend/models.py.jinja2 +31 -0
- sirius_cli/templates/backend/requirements.txt.jinja2 +13 -0
- sirius_cli/templates/backend/schemas.py.jinja2 +80 -0
- sirius_cli/templates/docker-compose.yml.jinja2 +27 -0
- sirius_cli/templates/frontend/.env.jinja2 +3 -0
- sirius_cli/templates/frontend/Dockerfile.jinja2 +12 -0
- sirius_cli/templates/frontend/index.html.jinja2 +16 -0
- sirius_cli/templates/frontend/package.json.jinja2 +28 -0
- sirius_cli/templates/frontend/postcss.config.js.jinja2 +6 -0
- sirius_cli/templates/frontend/src/App.tsx.jinja2 +89 -0
- sirius_cli/templates/frontend/src/Dashboard.tsx.jinja2 +210 -0
- sirius_cli/templates/frontend/src/TableCrud.tsx.jinja2 +567 -0
- sirius_cli/templates/frontend/src/index.css.jinja2 +25 -0
- sirius_cli/templates/frontend/src/main.tsx.jinja2 +10 -0
- sirius_cli/templates/frontend/tailwind.config.js.jinja2 +21 -0
- sirius_cli/templates/frontend/tsconfig.json.jinja2 +25 -0
- sirius_cli/templates/frontend/vite.config.ts.jinja2 +10 -0
- sirius_cli-0.1.0.dist-info/METADATA +165 -0
- sirius_cli-0.1.0.dist-info/RECORD +32 -0
- sirius_cli-0.1.0.dist-info/WHEEL +5 -0
- sirius_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sirius_cli-0.1.0.dist-info/licenses/LICENSE +619 -0
- 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,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
|
+
}
|