millas 0.2.7 → 0.2.9
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/package.json
CHANGED
|
@@ -165,37 +165,80 @@ class AdminResource {
|
|
|
165
165
|
const limit = perPage || this.perPage;
|
|
166
166
|
const offset = (page - 1) * limit;
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
// _db() is available on all ORM versions — it returns a raw knex table query.
|
|
169
|
+
// We build everything via knex directly so this works regardless of whether
|
|
170
|
+
// the ORM changes (changes3) have been applied.
|
|
171
|
+
let q = this.model._db().orderBy(sort, order);
|
|
169
172
|
|
|
170
|
-
// Search
|
|
173
|
+
// ── Search ───────────────────────────────────────────────────────────────
|
|
171
174
|
if (search && this.searchable.length) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
for (const col of
|
|
175
|
+
const cols = this.searchable;
|
|
176
|
+
q = q.where(function () {
|
|
177
|
+
for (const col of cols) {
|
|
175
178
|
this.orWhere(col, 'like', `%${search}%`);
|
|
176
179
|
}
|
|
177
180
|
});
|
|
178
181
|
}
|
|
179
182
|
|
|
180
|
-
// Filters
|
|
183
|
+
// ── Filters ──────────────────────────────────────────────────────────────
|
|
184
|
+
// Translate __ lookup syntax into knex calls so filter controls work
|
|
185
|
+
// even without the ORM changes applied.
|
|
181
186
|
for (const [key, value] of Object.entries(filters)) {
|
|
182
|
-
if (value
|
|
183
|
-
|
|
187
|
+
if (value === '' || value === null || value === undefined) continue;
|
|
188
|
+
|
|
189
|
+
const dunder = key.lastIndexOf('__');
|
|
190
|
+
if (dunder === -1) {
|
|
191
|
+
q = q.where(key, value);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const col = key.slice(0, dunder);
|
|
196
|
+
const lookup = key.slice(dunder + 2);
|
|
197
|
+
|
|
198
|
+
switch (lookup) {
|
|
199
|
+
case 'exact': q = q.where(col, value); break;
|
|
200
|
+
case 'not': q = q.where(col, '!=', value); break;
|
|
201
|
+
case 'gt': q = q.where(col, '>', value); break;
|
|
202
|
+
case 'gte': q = q.where(col, '>=', value); break;
|
|
203
|
+
case 'lt': q = q.where(col, '<', value); break;
|
|
204
|
+
case 'lte': q = q.where(col, '<=', value); break;
|
|
205
|
+
case 'isnull': q = value ? q.whereNull(col) : q.whereNotNull(col); break;
|
|
206
|
+
case 'in': q = q.whereIn(col, Array.isArray(value) ? value : [value]); break;
|
|
207
|
+
case 'notin': q = q.whereNotIn(col, Array.isArray(value) ? value : [value]); break;
|
|
208
|
+
case 'between': q = q.whereBetween(col, value); break;
|
|
209
|
+
case 'contains':
|
|
210
|
+
case 'icontains': q = q.where(col, 'like', `%${value}%`); break;
|
|
211
|
+
case 'startswith':
|
|
212
|
+
case 'istartswith': q = q.where(col, 'like', `${value}%`); break;
|
|
213
|
+
case 'endswith':
|
|
214
|
+
case 'iendswith': q = q.where(col, 'like', `%${value}`); break;
|
|
215
|
+
default: q = q.where(key, value); break;
|
|
184
216
|
}
|
|
185
217
|
}
|
|
186
218
|
|
|
187
|
-
// Date hierarchy
|
|
219
|
+
// ── Date hierarchy ────────────────────────────────────────────────────────
|
|
188
220
|
if (this.dateHierarchy) {
|
|
189
|
-
|
|
190
|
-
if (
|
|
221
|
+
const col = this.dateHierarchy;
|
|
222
|
+
if (year) {
|
|
223
|
+
// SQLite / MySQL / PG compatible
|
|
224
|
+
q = q.whereRaw(`strftime('%Y', "${col}") = ?`, [String(year)])
|
|
225
|
+
.catch
|
|
226
|
+
// If strftime not available (PG), fall through — best effort
|
|
227
|
+
|| q;
|
|
228
|
+
}
|
|
229
|
+
if (month) {
|
|
230
|
+
q = q.whereRaw(`strftime('%m', "${col}") = ?`, [String(month).padStart(2, '0')]);
|
|
231
|
+
}
|
|
191
232
|
}
|
|
192
233
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
234
|
+
// ── Execute ───────────────────────────────────────────────────────────────
|
|
235
|
+
const [rows, countResult] = await Promise.all([
|
|
236
|
+
q.clone().limit(limit).offset(offset),
|
|
237
|
+
q.clone().count('* as count').first(),
|
|
197
238
|
]);
|
|
198
239
|
|
|
240
|
+
const total = Number(countResult?.count ?? 0);
|
|
241
|
+
|
|
199
242
|
return {
|
|
200
243
|
data: rows.map(r => this.model._hydrate(r)),
|
|
201
244
|
total,
|
|
@@ -494,11 +537,11 @@ class AdminInline {
|
|
|
494
537
|
async fetchRows(parentId) {
|
|
495
538
|
if (!this.model || !this.foreignKey) return [];
|
|
496
539
|
try {
|
|
497
|
-
const rows = await this.model.
|
|
540
|
+
const rows = await this.model._db()
|
|
498
541
|
.where(this.foreignKey, parentId)
|
|
499
542
|
.limit(this.perPage)
|
|
500
|
-
.
|
|
501
|
-
return rows.map(r =>
|
|
543
|
+
.orderBy('id', 'desc');
|
|
544
|
+
return rows.map(r => this.model._hydrate ? this.model._hydrate(r) : r);
|
|
502
545
|
} catch { return []; }
|
|
503
546
|
}
|
|
504
547
|
|
|
@@ -1,459 +1,5 @@
|
|
|
1
1
|
{% extends "layouts/base.njk" %}
|
|
2
2
|
|
|
3
|
-
{% block title %}{{ resource.label }}{% endblock %}
|
|
4
|
-
{% block topbar_title %}
|
|
5
|
-
<span class="icon icon-16" style="color:var(--text-muted)">
|
|
6
|
-
<svg viewBox="0 0 24 24"><use href="#ic-{{ resource.icon or 'table' }}"/></svg>
|
|
7
|
-
</span>
|
|
8
|
-
{{ resource.label }}
|
|
9
|
-
<span class="badge badge-gray" style="font-size:11px;font-weight:500">{{ total }}</span>
|
|
10
|
-
{% endblock %}
|
|
11
|
-
|
|
12
|
-
{% block topbar_actions %}
|
|
13
|
-
{% if resource.canCreate %}
|
|
14
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/create" class="btn btn-primary">
|
|
15
|
-
<span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-plus"/></svg></span>
|
|
16
|
-
New {{ resource.singular }}
|
|
17
|
-
</a>
|
|
18
|
-
{% endif %}
|
|
19
|
-
{% endblock %}
|
|
20
|
-
|
|
21
|
-
{% block content %}
|
|
22
|
-
<div class="breadcrumb">
|
|
23
|
-
<a href="{{ adminPrefix }}/">
|
|
24
|
-
<span class="icon icon-12"><svg viewBox="0 0 24 24"><use href="#ic-grid"/></svg></span>
|
|
25
|
-
</a>
|
|
26
|
-
<span class="breadcrumb-sep">›</span>
|
|
27
|
-
<span class="breadcrumb-current">{{ resource.label }}</span>
|
|
28
|
-
{% if year %}
|
|
29
|
-
<span class="breadcrumb-sep">›</span>
|
|
30
|
-
<span class="breadcrumb-current">{{ year }}</span>
|
|
31
|
-
{% endif %}
|
|
32
|
-
{% if month %}
|
|
33
|
-
<span class="breadcrumb-sep">›</span>
|
|
34
|
-
<span class="breadcrumb-current">{{ month }}</span>
|
|
35
|
-
{% endif %}
|
|
36
|
-
</div>
|
|
37
|
-
|
|
38
|
-
{# ── Date hierarchy ── #}
|
|
39
|
-
{% if resource.dateHierarchy %}
|
|
40
|
-
<div class="date-hierarchy mb-4">
|
|
41
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}" class="dh-btn {% if not year %}active{% endif %}">All</a>
|
|
42
|
-
{% for y in dateYears %}
|
|
43
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}?year={{ y }}" class="dh-btn {% if year == y %}active{% endif %}">{{ y }}</a>
|
|
44
|
-
{% endfor %}
|
|
45
|
-
{% if year %}
|
|
46
|
-
<span class="dh-sep">›</span>
|
|
47
|
-
{% set monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] %}
|
|
48
|
-
{% for m in range(1, 13) %}
|
|
49
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}?year={{ year }}&month={{ m }}" class="dh-btn {% if month == m %}active{% endif %}">{{ monthNames[m-1] }}</a>
|
|
50
|
-
{% endfor %}
|
|
51
|
-
{% endif %}
|
|
52
|
-
</div>
|
|
53
|
-
{% endif %}
|
|
54
|
-
|
|
55
|
-
<div class="card">
|
|
56
|
-
|
|
57
|
-
{# ── Toolbar ── #}
|
|
58
|
-
<div class="toolbar">
|
|
59
|
-
<div class="toolbar-left">
|
|
60
|
-
<form method="GET" action="{{ adminPrefix }}/{{ resource.slug }}" id="search-form" style="display:flex;gap:8px;align-items:center">
|
|
61
|
-
<input type="hidden" name="sort" value="{{ sort }}">
|
|
62
|
-
<input type="hidden" name="order" value="{{ order }}">
|
|
63
|
-
<input type="hidden" name="perPage" value="{{ perPage }}">
|
|
64
|
-
{% if year %}<input type="hidden" name="year" value="{{ year }}">{% endif %}
|
|
65
|
-
{% if month %}<input type="hidden" name="month" value="{{ month }}">{% endif %}
|
|
66
|
-
{% for key, val in activeFilters %}
|
|
67
|
-
<input type="hidden" name="filter[{{ key }}]" value="{{ val }}">
|
|
68
|
-
{% endfor %}
|
|
69
|
-
<div class="search-wrap">
|
|
70
|
-
<span class="search-icon-inner">
|
|
71
|
-
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
72
|
-
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
73
|
-
</svg>
|
|
74
|
-
</span>
|
|
75
|
-
<input type="text" name="search" value="{{ search }}"
|
|
76
|
-
placeholder="Search {{ resource.label | lower }}…"
|
|
77
|
-
class="form-control search-input" autocomplete="off">
|
|
78
|
-
</div>
|
|
79
|
-
{% if search %}
|
|
80
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost btn-sm">
|
|
81
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
|
|
82
|
-
Clear
|
|
83
|
-
</a>
|
|
84
|
-
{% endif %}
|
|
85
|
-
</form>
|
|
86
|
-
</div>
|
|
87
|
-
|
|
88
|
-
<div class="toolbar-right">
|
|
89
|
-
{# Per-page selector #}
|
|
90
|
-
<select class="filter-control" style="min-width:80px" onchange="changePerPage(this.value)" title="Rows per page">
|
|
91
|
-
{% for n in [20, 50, 100] %}
|
|
92
|
-
<option value="{{ n }}" {% if perPage == n %}selected{% endif %}>{{ n }} / page</option>
|
|
93
|
-
{% endfor %}
|
|
94
|
-
</select>
|
|
95
|
-
|
|
96
|
-
{% if filters | length %}
|
|
97
|
-
<button class="btn btn-ghost btn-sm" onclick="toggleFilters()" id="filter-toggle">
|
|
98
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-filter"/></svg></span>
|
|
99
|
-
Filters
|
|
100
|
-
{% if activeFilters | length %}
|
|
101
|
-
<span class="badge badge-blue" style="font-size:10px;padding:1px 5px">{{ activeFilters | length }}</span>
|
|
102
|
-
{% endif %}
|
|
103
|
-
</button>
|
|
104
|
-
{% endif %}
|
|
105
|
-
|
|
106
|
-
{# Export dropdown #}
|
|
107
|
-
<div class="action-menu">
|
|
108
|
-
<button class="btn btn-ghost btn-sm action-menu-btn">
|
|
109
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
|
|
110
|
-
Export
|
|
111
|
-
<span class="icon icon-12"><svg viewBox="0 0 24 24"><use href="#ic-chevron-down"/></svg></span>
|
|
112
|
-
</button>
|
|
113
|
-
<div class="action-dropdown" style="right:0;min-width:160px">
|
|
114
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/export.csv?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
|
|
115
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
|
|
116
|
-
Export CSV
|
|
117
|
-
</a>
|
|
118
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/export.json?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
|
|
119
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
|
|
120
|
-
Export JSON
|
|
121
|
-
</a>
|
|
122
|
-
</div>
|
|
123
|
-
</div>
|
|
124
|
-
</div>
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
{# ── Bulk action bar ── #}
|
|
128
|
-
<div class="bulk-bar" id="bulk-bar">
|
|
129
|
-
<span class="bulk-count" id="bulk-count">0 selected</span>
|
|
130
|
-
<span class="text-muted" style="font-size:12px">—</span>
|
|
131
|
-
|
|
132
|
-
{# Built-in delete #}
|
|
133
|
-
{% if resource.canDelete %}
|
|
134
|
-
<button class="btn btn-ghost btn-sm btn-danger" onclick="bulkDelete()">
|
|
135
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
|
|
136
|
-
Delete selected
|
|
137
|
-
</button>
|
|
138
|
-
{% endif %}
|
|
139
|
-
|
|
140
|
-
{# Custom bulk actions #}
|
|
141
|
-
{% for action in resource.actions %}
|
|
142
|
-
<button class="btn btn-ghost btn-sm" onclick="submitBulkAction({{ action.index }})">
|
|
143
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ action.icon or 'check' }}"/></svg></span>
|
|
144
|
-
{{ action.label }}
|
|
145
|
-
</button>
|
|
146
|
-
{% endfor %}
|
|
147
|
-
|
|
148
|
-
<button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="clearSelection()">
|
|
149
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
|
|
150
|
-
Cancel
|
|
151
|
-
</button>
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
{# ── Filter panel ── #}
|
|
155
|
-
{% if filters | length %}
|
|
156
|
-
<div class="filter-row" id="filter-panel" style="display:none">
|
|
157
|
-
<form method="GET" action="{{ adminPrefix }}/{{ resource.slug }}" id="filter-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;width:100%">
|
|
158
|
-
<input type="hidden" name="search" value="{{ search }}">
|
|
159
|
-
<input type="hidden" name="sort" value="{{ sort }}">
|
|
160
|
-
<input type="hidden" name="order" value="{{ order }}">
|
|
161
|
-
<input type="hidden" name="perPage" value="{{ perPage }}">
|
|
162
|
-
|
|
163
|
-
{% for filter in filters %}
|
|
164
|
-
<div class="filter-group">
|
|
165
|
-
<label class="filter-label">{{ filter.label }}</label>
|
|
166
|
-
{% if filter.type == 'select' %}
|
|
167
|
-
<select name="filter[{{ filter.name }}]" class="filter-control" style="min-width:120px" onchange="this.form.submit()">
|
|
168
|
-
<option value="">All</option>
|
|
169
|
-
{% for opt in filter.options %}
|
|
170
|
-
<option value="{{ opt }}" {% if activeFilters[filter.name] == opt %}selected{% endif %}>{{ opt }}</option>
|
|
171
|
-
{% endfor %}
|
|
172
|
-
</select>
|
|
173
|
-
{% elif filter.type == 'boolean' %}
|
|
174
|
-
<select name="filter[{{ filter.name }}]" class="filter-control" style="min-width:100px" onchange="this.form.submit()">
|
|
175
|
-
<option value="">All</option>
|
|
176
|
-
<option value="1" {% if activeFilters[filter.name] == '1' %}selected{% endif %}>Yes</option>
|
|
177
|
-
<option value="0" {% if activeFilters[filter.name] == '0' %}selected{% endif %}>No</option>
|
|
178
|
-
</select>
|
|
179
|
-
{% elif filter.type == 'text' %}
|
|
180
|
-
<input type="text" name="filter[{{ filter.name }}]" class="filter-control" style="min-width:130px"
|
|
181
|
-
value="{{ activeFilters[filter.name] or '' }}" placeholder="Filter…">
|
|
182
|
-
{% elif filter.type == 'number' %}
|
|
183
|
-
<input type="number" name="filter[{{ filter.name }}]" class="filter-control" style="min-width:100px"
|
|
184
|
-
value="{{ activeFilters[filter.name] or '' }}" placeholder="0">
|
|
185
|
-
{% elif filter.type == 'dateRange' %}
|
|
186
|
-
<div style="display:flex;gap:6px;align-items:center">
|
|
187
|
-
<input type="date" name="filter[{{ filter.name }}__gte]" class="filter-control"
|
|
188
|
-
value="{{ activeFilters[filter.name + '__gte'] or '' }}" placeholder="From">
|
|
189
|
-
<span style="color:var(--text-muted);font-size:12px">→</span>
|
|
190
|
-
<input type="date" name="filter[{{ filter.name }}__lte]" class="filter-control"
|
|
191
|
-
value="{{ activeFilters[filter.name + '__lte'] or '' }}" placeholder="To">
|
|
192
|
-
</div>
|
|
193
|
-
{% endif %}
|
|
194
|
-
</div>
|
|
195
|
-
{% endfor %}
|
|
196
|
-
|
|
197
|
-
<div class="filter-group" style="flex-direction:row;align-items:flex-end;gap:6px;margin-left:auto">
|
|
198
|
-
<button type="submit" class="btn btn-ghost btn-sm">Apply</button>
|
|
199
|
-
{% if activeFilters | length %}
|
|
200
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}{% if search %}?search={{ search }}{% endif %}" class="btn btn-ghost btn-sm">
|
|
201
|
-
<span class="icon icon-12"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
|
|
202
|
-
Reset
|
|
203
|
-
</a>
|
|
204
|
-
{% endif %}
|
|
205
|
-
</div>
|
|
206
|
-
</form>
|
|
207
|
-
</div>
|
|
208
|
-
{% endif %}
|
|
209
|
-
|
|
210
|
-
{# ── Table ── #}
|
|
211
|
-
<div class="table-wrap">
|
|
212
|
-
{% if rows | length %}
|
|
213
|
-
<table id="data-table">
|
|
214
|
-
<thead>
|
|
215
|
-
<tr>
|
|
216
|
-
<th class="col-check">
|
|
217
|
-
<input type="checkbox" class="row-check" id="check-all" onchange="toggleAll(this)">
|
|
218
|
-
</th>
|
|
219
|
-
{% for field in listFields %}
|
|
220
|
-
{% set isSorted = sort == field.name %}
|
|
221
|
-
<th class="{% if field.name in sortable %}sortable{% endif %} {% if isSorted %}sort-{{ order }} sort-active{% endif %}">
|
|
222
|
-
{% if field.name in sortable %}
|
|
223
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}?sort={{ field.name }}&order={{ 'desc' if (isSorted and order == 'asc') else 'asc' }}&search={{ search }}&page={{ page }}&perPage={{ perPage }}" style="color:inherit;text-decoration:none;display:flex;align-items:center;gap:4px">
|
|
224
|
-
{{ field.label }}
|
|
225
|
-
<span class="sort-indicator"><span class="up"></span><span class="down"></span></span>
|
|
226
|
-
</a>
|
|
227
|
-
{% else %}
|
|
228
|
-
{{ field.label }}
|
|
229
|
-
{% endif %}
|
|
230
|
-
</th>
|
|
231
|
-
{% endfor %}
|
|
232
|
-
{% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
|
|
233
|
-
<th class="col-actions">Actions</th>
|
|
234
|
-
{% endif %}
|
|
235
|
-
</tr>
|
|
236
|
-
</thead>
|
|
237
|
-
<tbody>
|
|
238
|
-
{% for row in rows %}
|
|
239
|
-
<tr data-id="{{ row.id }}">
|
|
240
|
-
<td class="col-check">
|
|
241
|
-
<input type="checkbox" class="row-check item-check" value="{{ row.id }}" onchange="updateBulkBar()">
|
|
242
|
-
</td>
|
|
243
|
-
{% for field in listFields %}
|
|
244
|
-
<td class="{% if loop.first %}td-primary{% endif %}">
|
|
245
|
-
{# listDisplayLinks: make column value a link to detail page #}
|
|
246
|
-
{% if loop.first or field.name in resource.listDisplayLinks or field.isLink %}
|
|
247
|
-
{% if resource.canView %}
|
|
248
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}" style="color:inherit;text-decoration:none">
|
|
249
|
-
{{ row[field.name] | adminCell(field) | safe }}
|
|
250
|
-
</a>
|
|
251
|
-
{% else %}
|
|
252
|
-
{{ row[field.name] | adminCell(field) | safe }}
|
|
253
|
-
{% endif %}
|
|
254
|
-
{% else %}
|
|
255
|
-
{{ row[field.name] | adminCell(field) | safe }}
|
|
256
|
-
{% endif %}
|
|
257
|
-
</td>
|
|
258
|
-
{% endfor %}
|
|
259
|
-
{% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
|
|
260
|
-
<td class="col-actions">
|
|
261
|
-
<div class="action-menu">
|
|
262
|
-
<button class="action-menu-btn">
|
|
263
|
-
<span class="icon icon-14">
|
|
264
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
265
|
-
<circle cx="12" cy="5" r="1.2" fill="currentColor" stroke="none"/>
|
|
266
|
-
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none"/>
|
|
267
|
-
<circle cx="12" cy="19" r="1.2" fill="currentColor" stroke="none"/>
|
|
268
|
-
</svg>
|
|
269
|
-
</span>
|
|
270
|
-
</button>
|
|
271
|
-
<div class="action-dropdown">
|
|
272
|
-
{% if resource.canView %}
|
|
273
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}">
|
|
274
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-eye"/></svg></span>
|
|
275
|
-
View
|
|
276
|
-
</a>
|
|
277
|
-
{% endif %}
|
|
278
|
-
{% if resource.canEdit %}
|
|
279
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
|
|
280
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-edit"/></svg></span>
|
|
281
|
-
Edit
|
|
282
|
-
</a>
|
|
283
|
-
{% endif %}
|
|
284
|
-
{# Custom row actions #}
|
|
285
|
-
{% for ra in resource.rowActions %}
|
|
286
|
-
{% if ra.href %}
|
|
287
|
-
<a href="{{ ra.href }}" target="_blank" rel="noopener">
|
|
288
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'arrow-right' }}"/></svg></span>
|
|
289
|
-
{{ ra.label }}
|
|
290
|
-
</a>
|
|
291
|
-
{% elif ra.action %}
|
|
292
|
-
<form method="POST" action="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/action/{{ ra.action }}" style="display:contents">
|
|
293
|
-
<button type="submit">
|
|
294
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'check' }}"/></svg></span>
|
|
295
|
-
{{ ra.label }}
|
|
296
|
-
</button>
|
|
297
|
-
</form>
|
|
298
|
-
{% endif %}
|
|
299
|
-
{% endfor %}
|
|
300
|
-
{% if resource.canDelete %}
|
|
301
|
-
<div class="sep"></div>
|
|
302
|
-
<button class="danger" onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete', '#{{ row.id }}')">
|
|
303
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
|
|
304
|
-
Delete
|
|
305
|
-
</button>
|
|
306
|
-
{% endif %}
|
|
307
|
-
</div>
|
|
308
|
-
</div>
|
|
309
|
-
</td>
|
|
310
|
-
{% endif %}
|
|
311
|
-
</tr>
|
|
312
|
-
{% endfor %}
|
|
313
|
-
</tbody>
|
|
314
|
-
</table>
|
|
315
|
-
|
|
316
|
-
{# ── Pagination ── #}
|
|
317
|
-
{% if lastPage > 1 %}
|
|
318
|
-
<div class="pagination">
|
|
319
|
-
<a class="page-btn {% if page <= 1 %}disabled{% endif %}"
|
|
320
|
-
href="{{ adminPrefix }}/{{ resource.slug }}?page={{ page - 1 }}&sort={{ sort }}&order={{ order }}&search={{ search }}&perPage={{ perPage }}"
|
|
321
|
-
{% if page <= 1 %}tabindex="-1" aria-disabled="true"{% endif %}>
|
|
322
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-chevron-left"/></svg></span>
|
|
323
|
-
</a>
|
|
324
|
-
{% for p in range(1, lastPage + 1) %}
|
|
325
|
-
{% if p == 1 or p == lastPage or (p >= page - 2 and p <= page + 2) %}
|
|
326
|
-
<a class="page-btn {% if p == page %}active{% endif %}"
|
|
327
|
-
href="{{ adminPrefix }}/{{ resource.slug }}?page={{ p }}&sort={{ sort }}&order={{ order }}&search={{ search }}&perPage={{ perPage }}">{{ p }}</a>
|
|
328
|
-
{% elif p == 2 and page > 4 %}
|
|
329
|
-
<span class="page-ellipsis">…</span>
|
|
330
|
-
{% elif p == lastPage - 1 and page < lastPage - 3 %}
|
|
331
|
-
<span class="page-ellipsis">…</span>
|
|
332
|
-
{% endif %}
|
|
333
|
-
{% endfor %}
|
|
334
|
-
<a class="page-btn {% if page >= lastPage %}disabled{% endif %}"
|
|
335
|
-
href="{{ adminPrefix }}/{{ resource.slug }}?page={{ page + 1 }}&sort={{ sort }}&order={{ order }}&search={{ search }}&perPage={{ perPage }}"
|
|
336
|
-
{% if page >= lastPage %}tabindex="-1" aria-disabled="true"{% endif %}>
|
|
337
|
-
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-chevron-right"/></svg></span>
|
|
338
|
-
</a>
|
|
339
|
-
<span class="page-info">
|
|
340
|
-
{{ (page - 1) * perPage + 1 }}–{{ [page * perPage, total] | min }} of {{ total }}
|
|
341
|
-
</span>
|
|
342
|
-
</div>
|
|
343
|
-
{% endif %}
|
|
344
|
-
|
|
345
|
-
{% else %}
|
|
346
|
-
<div class="empty-state">
|
|
347
|
-
<div class="empty-icon">
|
|
348
|
-
<span class="icon icon-22"><svg viewBox="0 0 24 24"><use href="#ic-table"/></svg></span>
|
|
349
|
-
</div>
|
|
350
|
-
<div class="empty-title">No {{ resource.label }} found</div>
|
|
351
|
-
<div class="empty-desc">
|
|
352
|
-
{% if search %}No results for "<strong>{{ search }}</strong>".
|
|
353
|
-
{% elif activeFilters | length %}No records match the active filters.
|
|
354
|
-
{% else %}Get started by creating your first record.{% endif %}
|
|
355
|
-
</div>
|
|
356
|
-
{% if resource.canCreate %}
|
|
357
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/create" class="btn btn-primary">
|
|
358
|
-
<span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-plus"/></svg></span>
|
|
359
|
-
New {{ resource.singular }}
|
|
360
|
-
</a>
|
|
361
|
-
{% endif %}
|
|
362
|
-
</div>
|
|
363
|
-
{% endif %}
|
|
364
|
-
</div>
|
|
365
|
-
</div>
|
|
366
|
-
|
|
367
|
-
{# Hidden bulk action form #}
|
|
368
|
-
<form method="POST" action="{{ adminPrefix }}/{{ resource.slug }}/bulk-action" id="bulk-action-form" style="display:none">
|
|
369
|
-
<input type="hidden" name="actionIndex" id="bulk-action-index" value="">
|
|
370
|
-
<div id="bulk-action-ids"></div>
|
|
371
|
-
</form>
|
|
372
|
-
|
|
373
|
-
<style>
|
|
374
|
-
/* ── Date hierarchy ── */
|
|
375
|
-
.date-hierarchy {
|
|
376
|
-
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
|
|
377
|
-
padding: 10px 0;
|
|
378
|
-
}
|
|
379
|
-
.dh-btn {
|
|
380
|
-
padding: 4px 10px; border-radius: 99px; font-size: 12.5px;
|
|
381
|
-
border: 1px solid var(--border); background: var(--surface);
|
|
382
|
-
color: var(--text-muted); text-decoration: none; font-weight: 500;
|
|
383
|
-
transition: all .12s;
|
|
384
|
-
}
|
|
385
|
-
.dh-btn:hover { border-color: var(--primary); color: var(--primary); }
|
|
386
|
-
.dh-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
|
387
|
-
.dh-sep { color: var(--text-xmuted); font-size: 13px; padding: 0 2px; }
|
|
388
|
-
</style>
|
|
389
|
-
|
|
390
|
-
<script>
|
|
391
|
-
// ── Filter panel ──────────────────────────────────────────────
|
|
392
|
-
const filterPanel = document.getElementById('filter-panel');
|
|
393
|
-
{% if activeFilters | length %}
|
|
394
|
-
if (filterPanel) filterPanel.style.display = 'flex';
|
|
395
|
-
{% endif %}
|
|
396
|
-
function toggleFilters() {
|
|
397
|
-
if (!filterPanel) return;
|
|
398
|
-
filterPanel.style.display = filterPanel.style.display === 'none' ? 'flex' : 'none';
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ── Per-page selector ─────────────────────────────────────────
|
|
402
|
-
function changePerPage(val) {
|
|
403
|
-
const url = new URL(window.location.href);
|
|
404
|
-
url.searchParams.set('perPage', val);
|
|
405
|
-
url.searchParams.set('page', '1');
|
|
406
|
-
window.location.href = url.toString();
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// ── Bulk selection ────────────────────────────────────────────
|
|
410
|
-
const bulkBar = document.getElementById('bulk-bar');
|
|
411
|
-
const bulkCount = document.getElementById('bulk-count');
|
|
412
|
-
|
|
413
|
-
function updateBulkBar() {
|
|
414
|
-
const checked = document.querySelectorAll('.item-check:checked');
|
|
415
|
-
const n = checked.length;
|
|
416
|
-
bulkBar.classList.toggle('visible', n > 0);
|
|
417
|
-
if (bulkCount) bulkCount.textContent = `${n} selected`;
|
|
418
|
-
const all = document.getElementById('check-all');
|
|
419
|
-
if (all) {
|
|
420
|
-
const total = document.querySelectorAll('.item-check').length;
|
|
421
|
-
all.indeterminate = n > 0 && n < total;
|
|
422
|
-
all.checked = n > 0 && n === total;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function toggleAll(master) {
|
|
427
|
-
document.querySelectorAll('.item-check').forEach(c => { c.checked = master.checked; });
|
|
428
|
-
updateBulkBar();
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function clearSelection() {
|
|
432
|
-
document.querySelectorAll('.item-check, #check-all').forEach(c => { c.checked = false; c.indeterminate = false; });
|
|
433
|
-
updateBulkBar();
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function bulkDelete() {
|
|
437
|
-
const ids = [...document.querySelectorAll('.item-check:checked')].map(c => c.value);
|
|
438
|
-
if (!ids.length) return;
|
|
439
|
-
confirmDelete(
|
|
440
|
-
'{{ adminPrefix }}/{{ resource.slug }}/bulk-delete',
|
|
441
|
-
`${ids.length} record${ids.length > 1 ? 's' : ''}`
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function submitBulkAction(actionIndex) {
|
|
446
|
-
const ids = [...document.querySelectorAll('.item-check:checked')].map(c => c.value);
|
|
447
|
-
if (!ids.length) return;
|
|
448
|
-
const form = document.getElementById('bulk-action-form');
|
|
449
|
-
const container = document.getElementById('bulk-action-ids');
|
|
450
|
-
document.getElementById('bulk-action-index').value = actionIndex;
|
|
451
|
-
container.innerHTML = ids.map(id => `<input type="hidden" name="ids[]" value="${id}">`).join('');
|
|
452
|
-
form.submit();
|
|
453
|
-
}
|
|
454
|
-
</script>
|
|
455
|
-
{% endblock %}
|
|
456
|
-
|
|
457
3
|
{% block title %}{{ resource.label }}{% endblock %}
|
|
458
4
|
{% block topbar_title %}
|
|
459
5
|
<span class="icon icon-16" style="color:var(--text-muted)">
|