tina4ruby 3.13.148 → 3.13.150

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.
@@ -0,0 +1,26 @@
1
+ {#
2
+ crud/form.twig — the create/edit field inputs (and the public generate_form).
3
+
4
+ Override: drop your own templates/crud/form.twig to change how inputs render.
5
+
6
+ fields - one per input: { id, name, label, value, placeholder, required_attr,
7
+ and exactly ONE control flag: input (with type) | textarea | checkbox
8
+ (checked_attr) | select (options_html) }.
9
+ wrap - render the <form> wrapper; form_id_attr/action/form_method set it
10
+ edit - add the hidden primary-key input (edit modal)
11
+ modal_footer- render the Cancel/Save modal buttons; submit_button - a plain Submit
12
+
13
+ No inline styles or on*= handlers: the modal buttons carry data-crud-action and
14
+ are wired by the page script's delegated listener (ADR-0088).
15
+ #}
16
+ {% if wrap %}<form{{ form_id_attr|raw }} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
17
+ {% if method_override %}<input type="hidden" name="_method" value="{{ method_override }}">{% endif %}
18
+ {% if edit %}<input type="hidden" id="{{ mode }}-pk-value" name="{{ pk }}">{% endif %}
19
+ {% if modal_footer %}<div class="crud-errors" data-crud-errors hidden></div>{% endif %}
20
+ {% for field in fields %}<div class="mb-3">
21
+ <label for="{{ field.id }}" class="form-label">{{ field.label }}</label>
22
+ {% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{{ field.checked_attr|raw }}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.options_html|raw }}</select>{% endif %}{% if field.input %}<input type="{{ field.type }}" class="form-control" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{{ field.required_attr|raw }}>{% endif %}
23
+ </div>
24
+ {% endfor %}
25
+ {% if modal_footer %}<div class="modal-footer"><button type="button" class="btn btn-secondary" data-crud-action="close" data-crud-modal="{{ mode }}">Cancel</button><button type="button" class="btn btn-primary" data-crud-action="save" data-crud-mode="{{ mode }}">Save</button></div>{% endif %}{% if submit_button %}<button type="submit" class="btn btn-primary">Submit</button>{% endif %}
26
+ {% if wrap %}</form>{% endif %}
@@ -0,0 +1,31 @@
1
+ {#
2
+ crud/modals.twig — the create, edit and delete-confirm dialogs.
3
+
4
+ Override: drop your own templates/crud/modals.twig to restyle the dialogs.
5
+ create_form / edit_form are the crud/form.twig renders injected here. Buttons
6
+ carry data-crud-action (close / confirm-delete) and are wired by the page
7
+ script — no inline on*= handlers (ADR-0088).
8
+ #}
9
+ <div class="modal-overlay" id="modal-create">
10
+ <div class="modal-box">
11
+ <h3>Create New Record</h3>
12
+ {{ create_form|raw }}
13
+ </div>
14
+ </div>
15
+ <div class="modal-overlay" id="modal-edit">
16
+ <div class="modal-box">
17
+ <h3>Edit Record</h3>
18
+ {{ edit_form|raw }}
19
+ </div>
20
+ </div>
21
+ <div class="modal-overlay" id="modal-delete">
22
+ <div class="modal-box">
23
+ <h3>Confirm Delete</h3>
24
+ <p>Are you sure you want to delete this record? This action cannot be undone.</p>
25
+ <input type="hidden" id="delete-pk-value">
26
+ <div class="modal-footer">
27
+ <button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
28
+ <button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
29
+ </div>
30
+ </div>
31
+ </div>
@@ -0,0 +1,401 @@
1
+ {#
2
+ crud/page.twig — the CRUD admin page shell.
3
+
4
+ Override: drop your own templates/crud/page.twig in your app to restyle it.
5
+ This file owns the page chrome (title, search box, info line, table slot,
6
+ pagination, modals) and ONE nonce'd <script> that drives the grid over fetch()
7
+ against the AutoCrud REST endpoints. Tina4::Crud registers no routes of its own
8
+ (ADR-0094) — the backend is entirely AutoCrud.
9
+
10
+ Values handed in by the controller:
11
+ title, search - page title + current search term (escaped)
12
+ request_path - this page's path (for no-JS form/sort fallbacks)
13
+ info_count/total/page/total_pages - the summary line numbers
14
+ table_html - the server-rendered crud/table.twig (first page)
15
+ modals_html - the server-rendered crud/modals.twig
16
+ show_pagination, controls - the pager links
17
+ config_json - the grid's state + columns for the script
18
+
19
+ Styling is tina4-css only (Bootstrap-compatible). The one <style> block below
20
+ carries the CSP nonce (ADR-0088); there are NO inline style= or on*= attributes
21
+ anywhere — every control binds through addEventListener in the script.
22
+ #}
23
+ <!DOCTYPE html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="UTF-8">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
28
+ <title>{{ title }}</title>
29
+ <link rel="stylesheet" href="/css/tina4.min.css">
30
+ <style nonce="{{ csp_nonce() }}">
31
+ .crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
32
+ .crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
33
+ .crud-search { flex: 0 1 300px; max-width: 300px; }
34
+ .crud-actions { display: flex; gap: 0.5rem; align-items: center; }
35
+ .crud-actions .btn { white-space: nowrap; flex-shrink: 0; }
36
+ .crud-info { color: var(--text-muted, #6c757d); font-size: 0.875rem; margin-bottom: 0.5rem; }
37
+ .crud-empty { text-align: center; padding: 2rem; }
38
+ .crud-errors { margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 0.25rem;
39
+ background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
40
+ .crud-errors[hidden] { display: none; }
41
+ .crud-pagination { display: flex; justify-content: center; gap: 0.25rem; margin-top: 1rem; }
42
+ .sort-link { text-decoration: none; color: inherit; cursor: pointer; }
43
+ .sort-link:hover { text-decoration: underline; }
44
+ .sort-indicator { font-size: 0.75rem; }
45
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
46
+ background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
47
+ .modal-overlay.active { display: flex; }
48
+ .modal-box { background: var(--bg, #fff); border-radius: 0.5rem; padding: 1.5rem;
49
+ width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto;
50
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
51
+ .modal-box h3 { margin-top: 0; }
52
+ .modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; padding-left: 0; padding-right: 0; }
53
+ .alert { padding: 0.75rem 1rem; border-radius: 0.25rem; margin-bottom: 1rem; display: none; }
54
+ .alert.active { display: block; }
55
+ .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
56
+ .alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="crud-container">
61
+ <div id="crud-alert" class="alert"></div>
62
+ <div class="crud-header">
63
+ <h2>{{ title }}</h2>
64
+ <div class="crud-actions">
65
+ {# Live, as-you-type search (no Search button). Typing drives an AJAX
66
+ reload of the table; the term is preserved across sort + pagination. #}
67
+ <input type="text" data-crud-search value="{{ search }}" placeholder="Search..." class="form-control crud-search" aria-label="Search">
68
+ <button class="btn btn-primary" data-crud-action="create">+ New</button>
69
+ </div>
70
+ </div>
71
+ <div class="crud-info" data-crud-info>
72
+ Showing {{ info_count }} of {{ info_total }} records (page {{ info_page }} of {{ info_total_pages }})
73
+ </div>
74
+
75
+ {{ table_html|raw }}
76
+
77
+ <div class="crud-pagination" data-crud-pager>{% if show_pagination %}{% for c in controls %}{% if c.active %}<a class="btn btn-sm btn-primary" href="{{ c.url }}" data-crud-page="{{ c.page }}">{{ c.label }}</a>{% endif %}{% if c.inactive %}<a class="btn btn-sm btn-secondary" href="{{ c.url }}" data-crud-page="{{ c.page }}">{{ c.label }}</a>{% endif %}{% endfor %}{% endif %}</div>
78
+
79
+ {{ modals_html|raw }}
80
+
81
+ <script nonce="{{ csp_nonce() }}">
82
+ // The grid is AJAX-driven. CONFIG carries the current state and the column
83
+ // metadata; every change (search / sort / page) re-fetches the AutoCrud list
84
+ // endpoint and re-renders the table body + pager. CSP-clean: this element is
85
+ // nonce-authorised and every control binds through addEventListener — there
86
+ // is no inline on*= anywhere.
87
+ var CRUD = {{ config_json|raw }};
88
+ var CRUD_COLUMNS = CRUD.editable; // columns the create/edit modals fill
89
+ var state = {
90
+ search: CRUD.search || '',
91
+ sort: CRUD.sort || CRUD.pk,
92
+ sort_dir: CRUD.sort_dir || 'asc',
93
+ page: CRUD.page || 1
94
+ };
95
+ var controller = null; // in-flight fetch, so we can abort it
96
+ var searchTimer = null;
97
+
98
+ var elAlert = document.getElementById('crud-alert');
99
+ var elBody = document.querySelector('[data-crud-body]');
100
+ var elPager = document.querySelector('[data-crud-pager]');
101
+ var elInfo = document.querySelector('[data-crud-info]');
102
+ var elSearch = document.querySelector('[data-crud-search]');
103
+
104
+ function crudShowAlert(message, type) {
105
+ elAlert.className = 'alert alert-' + type + ' active';
106
+ elAlert.textContent = message;
107
+ setTimeout(function () { elAlert.className = 'alert'; }, 3000);
108
+ }
109
+
110
+ // --- Render the table body from a list-endpoint JSON page -----------------
111
+ // Values go in via textContent, so every cell is escaped by the DOM (no XSS).
112
+ function renderRows(records) {
113
+ elBody.textContent = '';
114
+ if (!records || records.length === 0) {
115
+ var tr = document.createElement('tr');
116
+ var td = document.createElement('td');
117
+ td.colSpan = CRUD.columns.length + 1;
118
+ td.className = 'crud-empty';
119
+ td.textContent = 'No records found.';
120
+ tr.appendChild(td);
121
+ elBody.appendChild(tr);
122
+ return;
123
+ }
124
+ records.forEach(function (record) {
125
+ var tr = document.createElement('tr');
126
+ tr.setAttribute('data-id', record[CRUD.pk]);
127
+ CRUD.columns.forEach(function (col) {
128
+ var td = document.createElement('td');
129
+ td.className = CRUD.aligns[col] || 'text-start';
130
+ var value = record[col];
131
+ td.textContent = (value === null || value === undefined) ? '' : value;
132
+ tr.appendChild(td);
133
+ });
134
+ var actions = document.createElement('td');
135
+ actions.className = 'text-end';
136
+ actions.appendChild(actionButton('edit', 'Edit', 'btn-primary me-1', record[CRUD.pk]));
137
+ actions.appendChild(actionButton('delete', 'Delete', 'btn-danger', record[CRUD.pk]));
138
+ tr.appendChild(actions);
139
+ elBody.appendChild(tr);
140
+ });
141
+ }
142
+
143
+ function actionButton(action, label, variant, id) {
144
+ var b = document.createElement('button');
145
+ b.className = 'btn btn-sm ' + variant;
146
+ b.setAttribute('data-crud-action', action);
147
+ b.setAttribute('data-id', id);
148
+ b.textContent = label;
149
+ return b;
150
+ }
151
+
152
+ function renderPager(data) {
153
+ elPager.textContent = '';
154
+ var totalPages = data.total_pages || 1;
155
+ if (totalPages <= 1) return;
156
+ if (data.page > 1) elPager.appendChild(pagerLink('Prev', data.page - 1, false));
157
+ var start = Math.max(data.page - 3, 1);
158
+ var end = Math.min(start + 6, totalPages);
159
+ start = Math.max(end - 6, 1);
160
+ for (var p = start; p <= end; p++) {
161
+ elPager.appendChild(pagerLink(String(p), p, p === data.page));
162
+ }
163
+ if (data.page < totalPages) elPager.appendChild(pagerLink('Next', data.page + 1, false));
164
+ }
165
+
166
+ function pagerLink(label, page, active) {
167
+ var a = document.createElement('a');
168
+ a.className = 'btn btn-sm ' + (active ? 'btn-primary' : 'btn-secondary');
169
+ a.setAttribute('href', '#');
170
+ a.setAttribute('data-crud-page', page);
171
+ a.textContent = label;
172
+ return a;
173
+ }
174
+
175
+ function updateInfo(data) {
176
+ if (!elInfo) return;
177
+ elInfo.textContent = 'Showing ' + (data.records ? data.records.length : 0) +
178
+ ' of ' + data.total + ' records (page ' + data.page + ' of ' + data.total_pages + ')';
179
+ }
180
+
181
+ // Reflect the active sort column + direction on the header arrows.
182
+ function updateSortIndicators() {
183
+ document.querySelectorAll('[data-crud-sort]').forEach(function (link) {
184
+ var old = link.querySelector('.sort-indicator');
185
+ if (old) old.remove();
186
+ var col = link.getAttribute('data-crud-sort');
187
+ if (col === state.sort) {
188
+ var span = document.createElement('span');
189
+ span.className = 'sort-indicator';
190
+ span.textContent = state.sort_dir === 'asc' ? ' ▲' : ' ▼';
191
+ link.appendChild(span);
192
+ link.setAttribute('data-crud-dir', state.sort_dir === 'asc' ? 'desc' : 'asc');
193
+ } else {
194
+ link.setAttribute('data-crud-dir', 'asc');
195
+ }
196
+ });
197
+ }
198
+
199
+ // Abort any in-flight request, then fetch the current state from AutoCrud.
200
+ function load() {
201
+ if (controller) controller.abort();
202
+ controller = new AbortController();
203
+ var params = new URLSearchParams();
204
+ params.set('search', state.search);
205
+ params.set('sort', state.sort);
206
+ params.set('sort_dir', state.sort_dir);
207
+ params.set('page', state.page);
208
+ params.set('per_page', CRUD.limit);
209
+ fetch(CRUD.api + '?' + params.toString(), { signal: controller.signal })
210
+ .then(function (r) { return r.json(); })
211
+ .then(function (data) {
212
+ renderRows(data.records || []);
213
+ renderPager(data);
214
+ updateInfo(data);
215
+ updateSortIndicators();
216
+ })
217
+ .catch(function (e) {
218
+ if (e.name !== 'AbortError') crudShowAlert('Failed to load: ' + e, 'danger');
219
+ });
220
+ }
221
+
222
+ // --- Wiring (all delegated, all CSP-clean) --------------------------------
223
+ if (elSearch) {
224
+ elSearch.addEventListener('input', function () {
225
+ clearTimeout(searchTimer);
226
+ var value = elSearch.value;
227
+ searchTimer = setTimeout(function () {
228
+ state.search = value;
229
+ state.page = 1;
230
+ load();
231
+ }, 300);
232
+ });
233
+ }
234
+
235
+ document.addEventListener('click', function (event) {
236
+ var sortLink = event.target.closest('[data-crud-sort]');
237
+ if (sortLink) {
238
+ event.preventDefault();
239
+ var col = sortLink.getAttribute('data-crud-sort');
240
+ if (state.sort === col) {
241
+ state.sort_dir = state.sort_dir === 'asc' ? 'desc' : 'asc';
242
+ } else {
243
+ state.sort = col;
244
+ state.sort_dir = 'asc';
245
+ }
246
+ state.page = 1;
247
+ load();
248
+ return;
249
+ }
250
+
251
+ var pageLink = event.target.closest('[data-crud-page]');
252
+ if (pageLink) {
253
+ event.preventDefault();
254
+ state.page = parseInt(pageLink.getAttribute('data-crud-page'), 10) || 1;
255
+ load();
256
+ return;
257
+ }
258
+
259
+ var button = event.target.closest('[data-crud-action]');
260
+ if (!button) return;
261
+ var action = button.dataset.crudAction;
262
+ if (action === 'create') {
263
+ crudShowCreate();
264
+ } else if (action === 'edit') {
265
+ crudShowEdit(button.dataset.id);
266
+ } else if (action === 'delete') {
267
+ crudShowDelete(button.dataset.id);
268
+ } else if (action === 'close') {
269
+ crudCloseModal(button.dataset.crudModal);
270
+ } else if (action === 'confirm-delete') {
271
+ crudConfirmDelete();
272
+ } else if (action === 'save') {
273
+ saveRecord(button.dataset.crudMode === 'edit' ? 'edit' : 'create');
274
+ }
275
+ });
276
+
277
+ // The modal forms post over fetch(), so stop their native submit here rather
278
+ // than with an onsubmit= attribute.
279
+ document.addEventListener('submit', function (event) {
280
+ if (event.target.closest('form[data-crud-form]')) {
281
+ event.preventDefault();
282
+ }
283
+ });
284
+
285
+ // --- Create / edit / delete modals ----------------------------------------
286
+ function crudShowCreate() {
287
+ var form = document.getElementById('form-create');
288
+ if (form) form.reset();
289
+ clearErrors('create');
290
+ document.getElementById('modal-create').classList.add('active');
291
+ }
292
+
293
+ function crudShowEdit(id) {
294
+ clearErrors('edit');
295
+ fetch(CRUD.api + '/' + id)
296
+ .then(function (r) { return r.json(); })
297
+ .then(function (result) {
298
+ var data = result.data || result;
299
+ document.getElementById('edit-pk-value').value = id;
300
+ CRUD_COLUMNS.forEach(function (col) {
301
+ var input = document.getElementById('edit-' + col);
302
+ if (input) input.value = data[col] != null ? data[col] : '';
303
+ });
304
+ document.getElementById('modal-edit').classList.add('active');
305
+ })
306
+ .catch(function (e) { crudShowAlert('Failed to load record: ' + e, 'danger'); });
307
+ }
308
+
309
+ function crudShowDelete(id) {
310
+ document.getElementById('delete-pk-value').value = id;
311
+ document.getElementById('modal-delete').classList.add('active');
312
+ }
313
+
314
+ function crudCloseModal(name) {
315
+ document.getElementById('modal-' + name).classList.remove('active');
316
+ }
317
+
318
+ function collectForm(prefix) {
319
+ var data = {};
320
+ CRUD_COLUMNS.forEach(function (col) {
321
+ var input = document.getElementById(prefix + '-' + col);
322
+ if (input) data[col] = input.value;
323
+ });
324
+ return data;
325
+ }
326
+
327
+ // Show / clear the in-modal validation error list (data-crud-errors).
328
+ function showErrors(mode, list) {
329
+ var box = document.querySelector('#modal-' + mode + ' [data-crud-errors]');
330
+ if (!box) return;
331
+ box.textContent = '';
332
+ (list || []).forEach(function (msg) {
333
+ var line = document.createElement('div');
334
+ line.textContent = msg;
335
+ box.appendChild(line);
336
+ });
337
+ box.hidden = !(list && list.length);
338
+ }
339
+
340
+ function clearErrors(mode) {
341
+ var box = document.querySelector('#modal-' + mode + ' [data-crud-errors]');
342
+ if (box) { box.textContent = ''; box.hidden = true; }
343
+ }
344
+
345
+ // Create (POST) or edit (PUT) through AutoCrud. On a failed save AutoCrud
346
+ // answers non-2xx with {errors:[...]} (or {error:...}); keep the modal OPEN
347
+ // and show the messages inline instead of reporting a false success.
348
+ function saveRecord(mode) {
349
+ clearErrors(mode);
350
+ var id = (mode === 'edit') ? document.getElementById('edit-pk-value').value : null;
351
+ var url = (mode === 'edit') ? CRUD.api + '/' + encodeURIComponent(id) : CRUD.api;
352
+ var method = (mode === 'edit') ? 'PUT' : 'POST';
353
+ fetch(url, {
354
+ method: method,
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify(collectForm(mode))
357
+ })
358
+ .then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); })
359
+ .then(function (res) {
360
+ var errors = res.body && (res.body.errors || (res.body.error ? [res.body.error] : null));
361
+ if (!res.ok || errors) {
362
+ showErrors(mode, errors || ['Save failed. Please check the fields and try again.']);
363
+ return;
364
+ }
365
+ crudCloseModal(mode);
366
+ crudShowAlert('Record ' + (mode === 'edit' ? 'updated' : 'created') + ' successfully', 'success');
367
+ load();
368
+ })
369
+ .catch(function (e) { showErrors(mode, ['Request failed: ' + e]); });
370
+ }
371
+
372
+ function crudConfirmDelete() {
373
+ var id = document.getElementById('delete-pk-value').value;
374
+ fetch(CRUD.api + '/' + id, { method: 'DELETE' })
375
+ .then(function (r) { return r.json(); })
376
+ .then(function (result) {
377
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
378
+ crudCloseModal('delete');
379
+ crudShowAlert('Record deleted successfully', 'success');
380
+ load();
381
+ })
382
+ .catch(function (e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
383
+ }
384
+
385
+ // Close a modal on backdrop click or the Escape key.
386
+ document.querySelectorAll('.modal-overlay').forEach(function (overlay) {
387
+ overlay.addEventListener('click', function (e) {
388
+ if (e.target === overlay) overlay.classList.remove('active');
389
+ });
390
+ });
391
+ document.addEventListener('keydown', function (e) {
392
+ if (e.key === 'Escape') {
393
+ document.querySelectorAll('.modal-overlay.active').forEach(function (m) {
394
+ m.classList.remove('active');
395
+ });
396
+ }
397
+ });
398
+ </script>
399
+ </div>
400
+ </body>
401
+ </html>
@@ -0,0 +1,69 @@
1
+ {#
2
+ crud/table.twig — the CRUD data table (headers + rows + row actions).
3
+
4
+ Override: drop your own templates/crud/table.twig in your app to restyle it.
5
+ It is rendered on its own by Tina4::Crud, so your copy wins over this one.
6
+
7
+ What the controller hands you:
8
+ headers - one per column: { label, align, sortable, col, next_dir, url,
9
+ indicator } (sortable) or { label, align, plain } (not sortable)
10
+ rows - one per record: { id, cells } (cells is the pre-built <td> run)
11
+ empty - true when there are no rows
12
+ colspan - column count + 1 (for the empty row)
13
+ editable - inline-edit table (Save/Delete); readonly - view table (Edit/Delete)
14
+ table_name, table_id_attr, inline_script
15
+
16
+ Alignment: each header/cell carries a tina4-css class (text-start for text,
17
+ text-end for numbers). Actions is always text-end. No inline styles (ADR-0088).
18
+
19
+ The initial page is server-rendered here; after it loads, the page's script
20
+ re-renders <tbody data-crud-body> over fetch() as the user searches/sorts/pages.
21
+ #}
22
+ <div class="table-responsive">
23
+ <table class="table table-striped table-hover"{{ table_id_attr|raw }}>
24
+ <thead class="table-dark"><tr>
25
+ {% for header in headers %}{% if header.sortable %}<th class="{{ header.align }}"><a class="sort-link" href="{{ header.url }}" data-crud-sort="{{ header.col }}" data-crud-dir="{{ header.next_dir }}">{{ header.label }}{{ header.indicator|raw }}</a></th>{% endif %}{% if header.plain %}<th class="{{ header.align }}">{{ header.label }}</th>{% endif %}{% endfor %}<th class="text-end">Actions</th>
26
+ </tr></thead>
27
+ <tbody data-crud-body>
28
+ {% if empty %}<tr><td colspan="{{ colspan }}" class="crud-empty">No records found.</td></tr>{% endif %}
29
+ {% for row in rows %}<tr data-id="{{ row.id }}">{{ row.cells|raw }}<td class="text-end">{% if editable %}<button class="btn btn-sm btn-primary me-1" data-crud-inline="save" data-table="{{ table_name }}" data-id="{{ row.id }}">Save</button><button class="btn btn-sm btn-danger" data-crud-inline="delete" data-table="{{ table_name }}" data-id="{{ row.id }}">Delete</button>{% endif %}{% if readonly %}<button class="btn btn-sm btn-primary me-1" data-crud-action="edit" data-id="{{ row.id }}">Edit</button><button class="btn btn-sm btn-danger" data-crud-action="delete" data-id="{{ row.id }}">Delete</button>{% endif %}</td></tr>
30
+ {% endfor %}
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+ {% if inline_script %}
35
+ {# Inline-edit fragment (Tina4::Crud.generate_table). CSP-clean: a nonce'd
36
+ <script> plus data-crud-inline buttons bound through one delegated listener,
37
+ never an inline on*= attribute. #}
38
+ <script nonce="{{ csp_nonce() }}">
39
+ function crudSave(table, id) {
40
+ var row = document.querySelector('tr[data-id="' + id + '"]');
41
+ var cells = row.querySelectorAll('td[data-field]');
42
+ var data = {};
43
+ cells.forEach(function (cell) { data[cell.dataset.field] = cell.textContent; });
44
+ fetch('/api/' + table + '/' + id, {
45
+ method: 'PUT',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify(data)
48
+ }).then(function (r) { return r.json(); }).then(function () { }).catch(function () { });
49
+ }
50
+ function crudDelete(table, id) {
51
+ fetch('/api/' + table + '/' + id, { method: 'DELETE' })
52
+ .then(function (r) { return r.json(); })
53
+ .then(function () {
54
+ var row = document.querySelector('tr[data-id="' + id + '"]');
55
+ if (row) row.remove();
56
+ })
57
+ .catch(function () { });
58
+ }
59
+ document.addEventListener('click', function (event) {
60
+ var button = event.target.closest('[data-crud-inline]');
61
+ if (!button) return;
62
+ if (button.dataset.crudInline === 'save') {
63
+ crudSave(button.dataset.table, button.dataset.id);
64
+ } else if (button.dataset.crudInline === 'delete') {
65
+ crudDelete(button.dataset.table, button.dataset.id);
66
+ }
67
+ });
68
+ </script>
69
+ {% endif %}
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.148"
10
+ VERSION = "3.13.150"
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.148
4
+ version: 3.13.150
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -293,6 +293,7 @@ files:
293
293
  - lib/tina4/public/images/tina4-logo-icon.webp
294
294
  - lib/tina4/public/js/frond.js
295
295
  - lib/tina4/public/js/frond.min.js
296
+ - lib/tina4/public/js/tina4-dev-admin-split.html
296
297
  - lib/tina4/public/js/tina4-dev-admin.min.js
297
298
  - lib/tina4/public/js/tina4.min.js
298
299
  - lib/tina4/public/js/tina4js.min.js
@@ -354,6 +355,10 @@ files:
354
355
  - lib/tina4/swagger.rb
355
356
  - lib/tina4/template.rb
356
357
  - lib/tina4/templates/base.twig
358
+ - lib/tina4/templates/crud/form.twig
359
+ - lib/tina4/templates/crud/modals.twig
360
+ - lib/tina4/templates/crud/page.twig
361
+ - lib/tina4/templates/crud/table.twig
357
362
  - lib/tina4/templates/errors/302.twig
358
363
  - lib/tina4/templates/errors/401.twig
359
364
  - lib/tina4/templates/errors/403.twig