millas 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,82 +1,138 @@
1
1
  {% extends "layouts/base.njk" %}
2
2
 
3
3
  {% block title %}{{ resource.label }}{% endblock %}
4
- {% block topbar_title %}{{ resource.icon }} {{ 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-table"/></svg>
7
+ </span>
8
+ {{ resource.label }}
9
+ <span class="badge badge-gray" style="font-size:11px;font-weight:500">{{ total }}</span>
10
+ {% endblock %}
5
11
 
6
12
  {% block topbar_actions %}
7
13
  {% if resource.canCreate %}
8
14
  <a href="{{ adminPrefix }}/{{ resource.slug }}/create" class="btn btn-primary">
9
- <span>+</span> New {{ resource.singular }}
15
+ <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-plus"/></svg></span>
16
+ New {{ resource.singular }}
10
17
  </a>
11
18
  {% endif %}
12
19
  {% endblock %}
13
20
 
14
21
  {% block content %}
15
22
  <div class="breadcrumb">
16
- <a href="{{ adminPrefix }}/">Dashboard</a>
23
+ <a href="{{ adminPrefix }}/">
24
+ <span class="icon icon-12"><svg viewBox="0 0 24 24"><use href="#ic-grid"/></svg></span>
25
+ </a>
17
26
  <span class="breadcrumb-sep">›</span>
18
- <span>{{ resource.label }}</span>
27
+ <span class="breadcrumb-current">{{ resource.label }}</span>
19
28
  </div>
20
29
 
21
30
  <div class="card">
31
+
22
32
  {# ── Toolbar ── #}
23
- <div class="card-header">
24
- <span class="card-title text-muted text-sm">{{ total }} record{{ 's' if total != 1 }}</span>
25
- <div class="flex items-center gap-2 ml-auto">
33
+ <div class="toolbar">
34
+ <div class="toolbar-left">
26
35
  {# Search #}
27
- <form method="GET" action="{{ adminPrefix }}/{{ resource.slug }}" style="display:flex;gap:8px">
36
+ <form method="GET" action="{{ adminPrefix }}/{{ resource.slug }}" id="search-form" style="display:flex;gap:8px;align-items:center">
28
37
  <input type="hidden" name="sort" value="{{ sort }}">
29
38
  <input type="hidden" name="order" value="{{ order }}">
30
- {% if filters | length %}
31
39
  {% for key, val in activeFilters %}
32
40
  <input type="hidden" name="filter[{{ key }}]" value="{{ val }}">
33
41
  {% endfor %}
34
- {% endif %}
42
+
35
43
  <div class="search-wrap">
36
- <span class="search-icon">🔍</span>
44
+ <span class="search-icon-inner">
45
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
46
+ <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
47
+ </svg>
48
+ </span>
37
49
  <input
38
- type="text" name="search"
50
+ type="text"
51
+ name="search"
39
52
  value="{{ search }}"
40
- placeholder="Search..."
41
- class="form-control search-input">
53
+ placeholder="Search {{ resource.label | lower }}…"
54
+ class="form-control search-input"
55
+ autocomplete="off">
42
56
  </div>
43
- <button type="submit" class="btn btn-ghost">Search</button>
44
57
  {% if search %}
45
- <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost">✕ Clear</a>
58
+ <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost btn-sm" title="Clear search">
59
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
60
+ Clear
61
+ </a>
46
62
  {% endif %}
47
63
  </form>
48
64
  </div>
65
+
66
+ <div class="toolbar-right">
67
+ {% if filters | length %}
68
+ <button class="btn btn-ghost btn-sm" onclick="toggleFilters()" id="filter-toggle">
69
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-filter"/></svg></span>
70
+ Filters
71
+ {% if activeFilters | length %}
72
+ <span class="badge badge-blue" style="font-size:10px;padding:1px 5px">{{ activeFilters | length }}</span>
73
+ {% endif %}
74
+ </button>
75
+ {% endif %}
76
+ </div>
49
77
  </div>
50
78
 
51
- {# ── Filters row ── #}
79
+ {# ── Bulk action bar ── #}
80
+ <div class="bulk-bar" id="bulk-bar">
81
+ <span class="bulk-count" id="bulk-count">0 selected</span>
82
+ <span class="text-muted" style="font-size:12px">—</span>
83
+ <button class="btn btn-ghost btn-sm btn-danger" onclick="bulkDelete()">
84
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
85
+ Delete selected
86
+ </button>
87
+ <button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="clearSelection()">
88
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
89
+ Cancel
90
+ </button>
91
+ </div>
92
+
93
+ {# ── Filter panel ── #}
52
94
  {% if filters | length %}
53
- <div style="padding:12px 20px;border-bottom:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap">
54
- <form method="GET" action="{{ adminPrefix }}/{{ resource.slug }}" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
95
+ <div class="filter-row" id="filter-panel" style="display:none">
96
+ <form method="GET" action="{{ adminPrefix }}/{{ resource.slug }}" id="filter-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;width:100%">
55
97
  <input type="hidden" name="search" value="{{ search }}">
56
98
  <input type="hidden" name="sort" value="{{ sort }}">
57
99
  <input type="hidden" name="order" value="{{ order }}">
100
+
58
101
  {% for filter in filters %}
59
- <div style="display:flex;flex-direction:column;gap:4px">
60
- <label class="form-label" style="font-size:10px">{{ filter.label }}</label>
102
+ <div class="filter-group">
103
+ <label class="filter-label">{{ filter.label }}</label>
61
104
  {% if filter.type == 'select' %}
62
- <select name="filter[{{ filter.name }}]" class="form-control" style="width:auto;min-width:120px" onchange="this.form.submit()">
105
+ <select name="filter[{{ filter.name }}]" class="filter-control" style="min-width:120px" onchange="this.form.submit()">
63
106
  <option value="">All</option>
64
107
  {% for opt in filter.options %}
65
108
  <option value="{{ opt }}" {% if activeFilters[filter.name] == opt %}selected{% endif %}>{{ opt }}</option>
66
109
  {% endfor %}
67
110
  </select>
68
111
  {% elif filter.type == 'boolean' %}
69
- <select name="filter[{{ filter.name }}]" class="form-control" style="width:auto;min-width:100px" onchange="this.form.submit()">
112
+ <select name="filter[{{ filter.name }}]" class="filter-control" style="min-width:100px" onchange="this.form.submit()">
70
113
  <option value="">All</option>
71
114
  <option value="1" {% if activeFilters[filter.name] == '1' %}selected{% endif %}>Yes</option>
72
115
  <option value="0" {% if activeFilters[filter.name] == '0' %}selected{% endif %}>No</option>
73
116
  </select>
117
+ {% elif filter.type == 'text' %}
118
+ <input type="text" name="filter[{{ filter.name }}]" class="filter-control" style="min-width:130px"
119
+ value="{{ activeFilters[filter.name] or '' }}" placeholder="Filter…">
120
+ {% elif filter.type == 'dateRange' %}
121
+ <input type="date" name="filter[{{ filter.name }}__gte]" class="filter-control"
122
+ value="{{ activeFilters[filter.name + '__gte'] or '' }}">
74
123
  {% endif %}
75
124
  </div>
76
125
  {% endfor %}
77
- {% if activeFilters | length %}
78
- <a href="{{ adminPrefix }}/{{ resource.slug }}{% if search %}?search={{ search }}{% endif %}" class="btn btn-ghost btn-sm" style="align-self:flex-end">✕ Reset filters</a>
79
- {% endif %}
126
+
127
+ <div class="filter-group" style="flex-direction:row;align-items:flex-end;gap:6px;margin-left:auto">
128
+ <button type="submit" class="btn btn-ghost btn-sm">Apply</button>
129
+ {% if activeFilters | length %}
130
+ <a href="{{ adminPrefix }}/{{ resource.slug }}{% if search %}?search={{ search }}{% endif %}" class="btn btn-ghost btn-sm">
131
+ <span class="icon icon-12"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
132
+ Reset
133
+ </a>
134
+ {% endif %}
135
+ </div>
80
136
  </form>
81
137
  </div>
82
138
  {% endif %}
@@ -84,14 +140,27 @@
84
140
  {# ── Table ── #}
85
141
  <div class="table-wrap">
86
142
  {% if rows | length %}
87
- <table>
143
+ <table id="data-table">
88
144
  <thead>
89
145
  <tr>
146
+ <th class="col-check">
147
+ <input type="checkbox" class="row-check" id="check-all" onchange="toggleAll(this)" title="Select all">
148
+ </th>
90
149
  {% for field in listFields %}
91
- <th class="{% if field.name in sortable %}sortable{% endif %} {% if sort == field.name %}sort-{{ order }}{% endif %}">
150
+ {% set isSorted = sort == field.name %}
151
+ <th class="
152
+ {% if field.name in sortable %}sortable{% endif %}
153
+ {% if isSorted %}sort-{{ order }} sort-active{% endif %}
154
+ ">
92
155
  {% if field.name in sortable %}
93
- <a href="{{ adminPrefix }}/{{ resource.slug }}?sort={{ field.name }}&order={{ 'desc' if (sort == field.name and order == 'asc') else 'asc' }}&search={{ search }}&page={{ page }}" style="color:inherit;text-decoration:none">
156
+ <a href="{{ adminPrefix }}/{{ resource.slug }}?sort={{ field.name }}&order={{ 'desc' if (isSorted and order == 'asc') else 'asc' }}&search={{ search }}&page={{ page }}" style="color:inherit;text-decoration:none;display:flex;align-items:center;gap:4px">
94
157
  {{ field.label }}
158
+ {% if field.name in sortable %}
159
+ <span class="sort-indicator">
160
+ <span class="up"></span>
161
+ <span class="down"></span>
162
+ </span>
163
+ {% endif %}
95
164
  </a>
96
165
  {% else %}
97
166
  {{ field.label }}
@@ -105,19 +174,44 @@
105
174
  </thead>
106
175
  <tbody>
107
176
  {% for row in rows %}
108
- <tr>
177
+ <tr data-id="{{ row.id }}">
178
+ <td class="col-check">
179
+ <input type="checkbox" class="row-check item-check" value="{{ row.id }}" onchange="updateBulkBar()">
180
+ </td>
109
181
  {% for field in listFields %}
110
- <td>{{ row[field.name] | adminCell(field) | safe }}</td>
182
+ <td class="{% if loop.first %}td-primary{% endif %}">{{ row[field.name] | adminCell(field) | safe }}</td>
111
183
  {% endfor %}
112
184
  {% if resource.canEdit or resource.canDelete %}
113
185
  <td class="col-actions">
114
- <div class="flex items-center gap-2" style="justify-content:flex-end">
115
- {% if resource.canEdit %}
116
- <a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
117
- {% endif %}
118
- {% if resource.canDelete %}
119
- <button onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete', '#{{ row.id }}')" class="btn btn-danger btn-sm">Delete</button>
120
- {% endif %}
186
+ <div class="action-menu">
187
+ <button class="action-menu-btn">
188
+ <span class="icon icon-14">
189
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
190
+ <circle cx="12" cy="5" r="1.2" fill="currentColor" stroke="none"/>
191
+ <circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none"/>
192
+ <circle cx="12" cy="19" r="1.2" fill="currentColor" stroke="none"/>
193
+ </svg>
194
+ </span>
195
+ </button>
196
+ <div class="action-dropdown">
197
+ {% if resource.canEdit %}
198
+ <a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
199
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-edit"/></svg></span>
200
+ Edit
201
+ </a>
202
+ {% endif %}
203
+ <a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}" style="display:none">
204
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-eye"/></svg></span>
205
+ View
206
+ </a>
207
+ {% if resource.canDelete %}
208
+ <div class="sep"></div>
209
+ <button class="danger" onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete', '#{{ row.id }}')">
210
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
211
+ Delete
212
+ </button>
213
+ {% endif %}
214
+ </div>
121
215
  </div>
122
216
  </td>
123
217
  {% endif %}
@@ -129,18 +223,31 @@
129
223
  {# ── Pagination ── #}
130
224
  {% if lastPage > 1 %}
131
225
  <div class="pagination">
132
- <button class="page-btn" {% if page <= 1 %}disabled{% endif %}
133
- onclick="location.href='{{ adminPrefix }}/{{ resource.slug }}?page={{ page - 1 }}&sort={{ sort }}&order={{ order }}&search={{ search }}'">‹</button>
226
+ <a class="page-btn {% if page <= 1 %}disabled{% endif %}"
227
+ href="{{ adminPrefix }}/{{ resource.slug }}?page={{ page - 1 }}&sort={{ sort }}&order={{ order }}&search={{ search }}"
228
+ {% if page <= 1 %}tabindex="-1" aria-disabled="true"{% endif %}>
229
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-chevron-left"/></svg></span>
230
+ </a>
231
+
134
232
  {% for p in range(1, lastPage + 1) %}
135
- {% if p <= 3 or p >= lastPage - 1 or (p >= page - 1 and p <= page + 1) %}
136
- <button class="page-btn {% if p == page %}active{% endif %}"
137
- onclick="location.href='{{ adminPrefix }}/{{ resource.slug }}?page={{ p }}&sort={{ sort }}&order={{ order }}&search={{ search }}'">{{ p }}</button>
138
- {% elif p == 4 and page > 5 %}
139
- <span style="color:var(--text-muted);padding:0 4px">…</span>
233
+ {% if p == 1 or p == lastPage or (p >= page - 2 and p <= page + 2) %}
234
+ <a class="page-btn {% if p == page %}active{% endif %}"
235
+ href="{{ adminPrefix }}/{{ resource.slug }}?page={{ p }}&sort={{ sort }}&order={{ order }}&search={{ search }}">
236
+ {{ p }}
237
+ </a>
238
+ {% elif p == 2 and page > 4 %}
239
+ <span class="page-ellipsis">…</span>
240
+ {% elif p == lastPage - 1 and page < lastPage - 3 %}
241
+ <span class="page-ellipsis">…</span>
140
242
  {% endif %}
141
243
  {% endfor %}
142
- <button class="page-btn" {% if page >= lastPage %}disabled{% endif %}
143
- onclick="location.href='{{ adminPrefix }}/{{ resource.slug }}?page={{ page + 1 }}&sort={{ sort }}&order={{ order }}&search={{ search }}'">›</button>
244
+
245
+ <a class="page-btn {% if page >= lastPage %}disabled{% endif %}"
246
+ href="{{ adminPrefix }}/{{ resource.slug }}?page={{ page + 1 }}&sort={{ sort }}&order={{ order }}&search={{ search }}"
247
+ {% if page >= lastPage %}tabindex="-1" aria-disabled="true"{% endif %}>
248
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-chevron-right"/></svg></span>
249
+ </a>
250
+
144
251
  <span class="page-info">
145
252
  {{ (page - 1) * perPage + 1 }}–{{ [page * perPage, total] | min }} of {{ total }}
146
253
  </span>
@@ -149,16 +256,85 @@
149
256
 
150
257
  {% else %}
151
258
  <div class="empty-state">
152
- <div class="empty-icon">{{ resource.icon }}</div>
259
+ <div class="empty-icon">
260
+ <span class="icon icon-22"><svg viewBox="0 0 24 24"><use href="#ic-table"/></svg></span>
261
+ </div>
153
262
  <div class="empty-title">No {{ resource.label }} found</div>
154
263
  <div class="empty-desc">
155
- {% if search %}No results for "{{ search }}".{% else %}Get started by creating your first record.{% endif %}
264
+ {% if search %}
265
+ No results for "<strong>{{ search }}</strong>". Try a different search term.
266
+ {% elif activeFilters | length %}
267
+ No records match the active filters.
268
+ {% else %}
269
+ Get started by creating your first record.
270
+ {% endif %}
156
271
  </div>
157
272
  {% if resource.canCreate %}
158
- <a href="{{ adminPrefix }}/{{ resource.slug }}/create" class="btn btn-primary">+ New {{ resource.singular }}</a>
273
+ <a href="{{ adminPrefix }}/{{ resource.slug }}/create" class="btn btn-primary">
274
+ <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-plus"/></svg></span>
275
+ New {{ resource.singular }}
276
+ </a>
159
277
  {% endif %}
160
278
  </div>
161
279
  {% endif %}
162
280
  </div>
163
281
  </div>
282
+
283
+ <script>
284
+ // ── Filter panel toggle ──────────────────────────────────────
285
+ const filterPanel = document.getElementById('filter-panel');
286
+ {% if activeFilters | length %}
287
+ if (filterPanel) filterPanel.style.display = 'flex';
288
+ {% endif %}
289
+
290
+ function toggleFilters() {
291
+ if (!filterPanel) return;
292
+ const shown = filterPanel.style.display !== 'none';
293
+ filterPanel.style.display = shown ? 'none' : 'flex';
294
+ }
295
+
296
+ // ── Live search on Enter ──────────────────────────────────────
297
+ const searchForm = document.getElementById('search-form');
298
+ if (searchForm) {
299
+ searchForm.querySelector('input[name="search"]')?.addEventListener('keydown', e => {
300
+ if (e.key === 'Enter') searchForm.submit();
301
+ });
302
+ }
303
+
304
+ // ── Bulk selection ────────────────────────────────────────────
305
+ const bulkBar = document.getElementById('bulk-bar');
306
+ const bulkCount = document.getElementById('bulk-count');
307
+
308
+ function updateBulkBar() {
309
+ const checked = document.querySelectorAll('.item-check:checked');
310
+ const n = checked.length;
311
+ bulkBar.classList.toggle('visible', n > 0);
312
+ if (bulkCount) bulkCount.textContent = `${n} selected`;
313
+ const all = document.getElementById('check-all');
314
+ if (all) {
315
+ const total = document.querySelectorAll('.item-check').length;
316
+ all.indeterminate = n > 0 && n < total;
317
+ all.checked = n > 0 && n === total;
318
+ }
319
+ }
320
+
321
+ function toggleAll(master) {
322
+ document.querySelectorAll('.item-check').forEach(c => { c.checked = master.checked; });
323
+ updateBulkBar();
324
+ }
325
+
326
+ function clearSelection() {
327
+ document.querySelectorAll('.item-check, #check-all').forEach(c => { c.checked = false; c.indeterminate = false; });
328
+ updateBulkBar();
329
+ }
330
+
331
+ function bulkDelete() {
332
+ const ids = [...document.querySelectorAll('.item-check:checked')].map(c => c.value);
333
+ if (!ids.length) return;
334
+ confirmDelete(
335
+ '{{ adminPrefix }}/{{ resource.slug }}/bulk-delete',
336
+ `${ids.length} record${ids.length > 1 ? 's' : ''}`
337
+ );
338
+ }
339
+ </script>
164
340
  {% endblock %}