millas 0.2.12-beta-2 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/admin/Admin.js +122 -38
  3. package/src/admin/ViewContext.js +12 -3
  4. package/src/admin/resources/AdminResource.js +10 -0
  5. package/src/admin/static/admin.css +95 -14
  6. package/src/admin/views/layouts/base.njk +23 -34
  7. package/src/admin/views/pages/detail.njk +16 -5
  8. package/src/admin/views/pages/error.njk +65 -0
  9. package/src/admin/views/pages/list.njk +127 -2
  10. package/src/admin/views/partials/form-scripts.njk +7 -3
  11. package/src/admin/views/partials/form-widget.njk +2 -1
  12. package/src/admin/views/partials/icons.njk +64 -0
  13. package/src/ai/AIManager.js +954 -0
  14. package/src/ai/AITokenBudget.js +250 -0
  15. package/src/ai/PromptGuard.js +216 -0
  16. package/src/ai/agents.js +218 -0
  17. package/src/ai/conversation.js +213 -0
  18. package/src/ai/drivers.js +734 -0
  19. package/src/ai/files.js +249 -0
  20. package/src/ai/media.js +303 -0
  21. package/src/ai/pricing.js +152 -0
  22. package/src/ai/provider_tools.js +114 -0
  23. package/src/ai/types.js +356 -0
  24. package/src/commands/createsuperuser.js +17 -4
  25. package/src/commands/serve.js +2 -4
  26. package/src/container/AppInitializer.js +39 -15
  27. package/src/container/Application.js +31 -1
  28. package/src/core/foundation.js +1 -1
  29. package/src/errors/HttpError.js +32 -16
  30. package/src/facades/AI.js +411 -0
  31. package/src/facades/Hash.js +67 -0
  32. package/src/facades/Process.js +144 -0
  33. package/src/hashing/Hash.js +262 -0
  34. package/src/http/HtmlEscape.js +162 -0
  35. package/src/http/MillasRequest.js +63 -7
  36. package/src/http/MillasResponse.js +70 -4
  37. package/src/http/ResponseDispatcher.js +21 -27
  38. package/src/http/SafeFilePath.js +195 -0
  39. package/src/http/SafeRedirect.js +62 -0
  40. package/src/http/SecurityBootstrap.js +70 -0
  41. package/src/http/helpers.js +40 -125
  42. package/src/http/index.js +10 -1
  43. package/src/http/middleware/CsrfMiddleware.js +258 -0
  44. package/src/http/middleware/RateLimiter.js +314 -0
  45. package/src/http/middleware/SecurityHeaders.js +281 -0
  46. package/src/i18n/Translator.js +10 -2
  47. package/src/logger/LogRedactor.js +247 -0
  48. package/src/logger/Logger.js +1 -1
  49. package/src/logger/formatters/JsonFormatter.js +11 -4
  50. package/src/logger/formatters/PrettyFormatter.js +3 -1
  51. package/src/logger/formatters/SimpleFormatter.js +14 -3
  52. package/src/middleware/ThrottleMiddleware.js +27 -4
  53. package/src/process/Process.js +333 -0
  54. package/src/router/MiddlewareRegistry.js +27 -2
  55. package/src/scaffold/templates.js +3 -0
  56. package/src/validation/Validator.js +348 -607
  57. package/src/admin.zip +0 -0
@@ -99,8 +99,11 @@
99
99
  <div class="detail-label">{{ field.label }}</div>
100
100
  <div class="detail-value">
101
101
  {% set val = record[field.name] %}
102
- {% include "partials/detail-value.njk" ignore missing %}
103
- {{ val | adminCell(field) | safe if val is not none else '<span class="cell-muted">—</span>' }}
102
+ {% if val is not none %}
103
+ {{ val | adminCell(field) | safe }}
104
+ {% else %}
105
+ <span class="cell-muted">—</span>
106
+ {% endif %}
104
107
  </div>
105
108
  </div>
106
109
  {% endif %}
@@ -125,7 +128,11 @@
125
128
  <div class="detail-label">{{ field.label }}</div>
126
129
  <div class="detail-value">
127
130
  {% set val = record[field.name] %}
128
- {{ val | adminDetail(field) | safe }}
131
+ {% if val is not none %}
132
+ {{ val | adminDetail(field) | safe }}
133
+ {% else %}
134
+ <span class="cell-muted">—</span>
135
+ {% endif %}
129
136
  </div>
130
137
  </div>
131
138
  {% endif %}
@@ -246,7 +253,11 @@
246
253
  <tr>
247
254
  {% for field in inline.fields %}
248
255
  <td {% if loop.first %}class="td-primary"{% endif %}>
249
- {{ row[field.name] | adminCell(field) | safe if row[field.name] is not none else '<span class="cell-muted">—</span>' }}
256
+ {% if row[field.name] is not none %}
257
+ {{ row[field.name] | adminCell(field) | safe }}
258
+ {% else %}
259
+ <span class="cell-muted">—</span>
260
+ {% endif %}
250
261
  </td>
251
262
  {% endfor %}
252
263
  {% if inline.canDelete %}
@@ -343,4 +354,4 @@
343
354
  document.querySelectorAll('.tab-panel').forEach((p, i) => p.classList.toggle('active', i === idx));
344
355
  }
345
356
  </script>
346
- {% endblock %}
357
+ {% endblock %}
@@ -0,0 +1,65 @@
1
+ {% extends "layouts/base.njk" %}
2
+
3
+ {% block title %}{{ errorTitle }}{% endblock %}
4
+
5
+ {% block topbar_title %}
6
+ <span style="color:var(--danger)">{{ errorTitle }}</span>
7
+ {% endblock %}
8
+ {% block sidebar %}
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div style="max-width:640px;margin:40px auto;padding:0 8px">
13
+
14
+ {# ── Icon + heading ── #}
15
+ <div style="display:flex;align-items:center;gap:14px;margin-bottom:28px">
16
+ <div style="flex-shrink:0;width:48px;height:48px;background:var(--danger-bg);border:1px solid var(--danger-border);border-radius:var(--radius-lg);display:flex;align-items:center;justify-content:center">
17
+ {% if errorStatus == 404 %}
18
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
19
+ <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
20
+ <line x1="11" y1="8" x2="11" y2="11"/><line x1="11" y1="14" x2="11.01" y2="14"/>
21
+ </svg>
22
+ {% else %}
23
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
24
+ <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
25
+ </svg>
26
+ {% endif %}
27
+ </div>
28
+ <div>
29
+ <div style="font-size:22px;font-weight:700;color:var(--danger);line-height:1.2">{{ errorStatus }}</div>
30
+ <div style="font-size:14px;color:var(--text-soft);margin-top:2px">{{ errorTitle }}</div>
31
+ </div>
32
+ </div>
33
+
34
+ {# ── Message card ── #}
35
+ <div class="card" style="margin-bottom:20px">
36
+ <div style="padding:20px 22px;display:flex;align-items:flex-start;gap:12px">
37
+ <svg style="flex-shrink:0;margin-top:1px;color:var(--text-muted)" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
38
+ <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
39
+ </svg>
40
+ <span style="font-size:14px;color:var(--text);line-height:1.6">{{ errorMsg }}</span>
41
+ </div>
42
+ </div>
43
+
44
+ {# ── Stack trace (dev only) ── #}
45
+ {% if errorStack %}
46
+ <details style="margin-bottom:20px">
47
+ <summary style="font-size:12.5px;color:var(--text-muted);cursor:pointer;user-select:none;margin-bottom:8px">Stack trace</summary>
48
+ <pre style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;font-size:11.5px;color:var(--text-soft);overflow-x:auto;line-height:1.65;margin:0;white-space:pre-wrap;word-break:break-word">{{ errorStack }}</pre>
49
+ </details>
50
+ {% endif %}
51
+
52
+ {# ── Actions ── #}
53
+ <div style="display:flex;align-items:center;gap:10px">
54
+ <a href="javascript:history.back()" class="btn btn-ghost btn-sm">
55
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
56
+ Go back
57
+ </a>
58
+ <a href="{{ adminPrefix }}/" class="btn btn-ghost btn-sm">
59
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
60
+ Dashboard
61
+ </a>
62
+ </div>
63
+
64
+ </div>
65
+ {% endblock %}
@@ -99,10 +99,18 @@
99
99
  <div class="bulk-bar" id="bulk-bar">
100
100
  <span class="bulk-count" id="bulk-count">0 selected</span>
101
101
  <span class="text-muted" style="font-size:12px">—</span>
102
+ {% for action in resource.actions %}
103
+ <button class="btn btn-ghost btn-sm" onclick="bulkAction({{ action.index }})">
104
+ {% if action.icon %}<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ action.icon }}"/></svg></span>{% endif %}
105
+ {{ action.label }}
106
+ </button>
107
+ {% endfor %}
108
+ {% if resource.canDelete %}
102
109
  <button class="btn btn-ghost btn-sm btn-danger" onclick="bulkDelete()">
103
110
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
104
111
  Delete selected
105
112
  </button>
113
+ {% endif %}
106
114
  <button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="clearSelection()">
107
115
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
108
116
  Cancel
@@ -186,7 +194,7 @@
186
194
  {% endif %}
187
195
  </th>
188
196
  {% endfor %}
189
- {% if resource.canEdit or resource.canDelete %}
197
+ {% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
190
198
  <th class="col-actions">Actions</th>
191
199
  {% endif %}
192
200
  </tr>
@@ -200,7 +208,7 @@
200
208
  {% for field in listFields %}
201
209
  <td class="{% if loop.first %}td-primary{% endif %}">{{ row[field.name] | adminCell(field) | safe }}</td>
202
210
  {% endfor %}
203
- {% if resource.canEdit or resource.canDelete %}
211
+ {% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
204
212
  <td class="col-actions">
205
213
  <div class="ui-menu">
206
214
  <button class="action-menu-btn ui-menu-trigger" type="button" aria-label="Row actions">
@@ -219,6 +227,24 @@
219
227
  Edit
220
228
  </a>
221
229
  {% endif %}
230
+ {% if row._rowActions | length %}
231
+ {% if resource.canEdit %}<div class="ui-menu-sep"></div>{% endif %}
232
+ {% for ra in row._rowActions %}
233
+ {% if ra.href %}
234
+ <a class="ui-menu-item" href="{{ ra.href }}" target="_blank" rel="noopener">
235
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'eye' }}"/></svg></span>
236
+ {{ ra.label }}
237
+ </a>
238
+ {% elif ra.action %}
239
+ <button class="ui-menu-item" type="button"
240
+ data-row-action="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/action/{{ ra.action }}"
241
+ data-row-action-label="{{ ra.label }}">
242
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'check' }}"/></svg></span>
243
+ {{ ra.label }}
244
+ </button>
245
+ {% endif %}
246
+ {% endfor %}
247
+ {% endif %}
222
248
  {% if resource.canDelete %}
223
249
  <div class="ui-menu-sep"></div>
224
250
  <button class="ui-menu-item ui-menu-danger" type="button"
@@ -362,6 +388,19 @@
362
388
  });
363
389
  };
364
390
 
391
+ window.bulkAction = function(actionIndex) {
392
+ var ids = $('.item-check:checked').map(function() { return this.value; }).get();
393
+ if (!ids.length) return;
394
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
395
+ var $form = $('<form method="POST">').attr('action', PREFIX + '/' + SLUG + '/bulk-action');
396
+ $form.append('<input name="_csrf" value="' + csrf + '">');
397
+ $form.append('<input type="hidden" name="actionIndex" value="' + actionIndex + '">');
398
+ $.each(ids, function(_, id) {
399
+ $form.append('<input type="hidden" name="ids[]" value="' + id + '">');
400
+ });
401
+ $form.appendTo('body').submit();
402
+ };
403
+
365
404
  // ── UI.Dropdown — portal-rendered action menus ────────────────────────────
366
405
  // Wire export menu
367
406
  var $exportBtn = $('#export-menu-btn');
@@ -399,6 +438,78 @@
399
438
  dd.close();
400
439
  confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
401
440
  });
441
+
442
+ $panel.find('[data-row-action]').on('click', function() {
443
+ dd.close();
444
+ var url = $(this).data('row-action');
445
+ var label = $(this).data('row-action-label');
446
+ UI.Confirm.show({
447
+ title: label,
448
+ message: 'Run <strong>' + label + '</strong> on this record?',
449
+ confirm: label,
450
+ }).then(function(ok) {
451
+ if (!ok) return;
452
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
453
+ var $form = $('<form method="POST">').attr('action', url);
454
+ $form.append('<input name="_csrf" value="' + csrf + '">');
455
+ $form.appendTo('body').submit();
456
+ });
457
+ });
458
+
459
+ // ── Right-click on the row opens a context menu at the cursor ──────────
460
+ (function($menuPanel) {
461
+ var $row = $menuPanel.closest('tr');
462
+
463
+ $row.on('contextmenu', function(e) {
464
+ e.preventDefault();
465
+ e.stopPropagation();
466
+
467
+ // Remove any existing portal
468
+ $('.context-menu-portal').remove();
469
+
470
+ // Build a fresh portal from the panel's inner HTML (avoids clone display:none)
471
+ var $p = $('<div class="ui-menu-panel context-menu-portal"></div>');
472
+ $p.html($menuPanel.html());
473
+ $p.css({
474
+ position: 'fixed',
475
+ top: Math.min(e.clientY, window.innerHeight - 240) + 'px',
476
+ left: Math.min(e.clientX, window.innerWidth - 190) + 'px',
477
+ zIndex: 9999,
478
+ });
479
+ $('body').append($p);
480
+
481
+ // Wire handlers on the portal
482
+ $p.find('[data-confirm-delete]').on('click', function() {
483
+ $p.remove();
484
+ confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
485
+ });
486
+ $p.find('[data-row-action]').on('click', function() {
487
+ $p.remove();
488
+ var _url = $(this).data('row-action');
489
+ var _label = $(this).data('row-action-label');
490
+ UI.Confirm.show({
491
+ title: _label,
492
+ message: 'Run <strong>' + _label + '</strong> on this record?',
493
+ confirm: _label,
494
+ }).then(function(ok) {
495
+ if (!ok) return;
496
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
497
+ var $form = $('<form method="POST">').attr('action', _url);
498
+ $form.append('<input name="_csrf" value="' + csrf + '">');
499
+ $form.appendTo('body').submit();
500
+ });
501
+ });
502
+ $p.find('a.ui-menu-item').on('click', function() { $p.remove(); });
503
+
504
+ // Dismiss on next click anywhere or Escape
505
+ setTimeout(function() {
506
+ $(document).one('click.ctxmenu', function() { $p.remove(); });
507
+ }, 0);
508
+ $(document).one('keydown.ctxmenu', function(ev) {
509
+ if (ev.key === 'Escape') { $p.remove(); }
510
+ });
511
+ });
512
+ })($panel);
402
513
  });
403
514
  });
404
515
  </script>
@@ -437,6 +548,20 @@
437
548
  .ui-menu-danger:hover { background: var(--danger-bg); color: var(--danger); }
438
549
 
439
550
  .ui-menu-sep { height: 1px; background: var(--border-soft); margin: 3px 0; }
551
+
552
+ /* ── Context menu portal ── */
553
+ .context-menu-portal {
554
+ background: var(--surface);
555
+ border: 1px solid var(--border);
556
+ border-radius: var(--radius);
557
+ min-width: 160px;
558
+ overflow: hidden;
559
+ box-shadow: 0 4px 16px rgba(0,0,0,.12), 0 1px 4px rgba(0,0,0,.08);
560
+ }
561
+
562
+ /* ── Right-click cursor hint on rows ── */
563
+ #data-table tbody tr { cursor: context-menu; }
564
+ #data-table tbody tr:hover { background: var(--surface2); }
440
565
  </style>
441
566
 
442
567
  {% endblock %}
@@ -133,8 +133,9 @@
133
133
  // Supabase-style: async, paginated, searchable, keyboard nav.
134
134
  // ══════════════════════════════════════════════════════════════
135
135
  (function () {
136
- const ADMIN_PREFIX = {{ adminPrefix | dump | safe }};
137
- const PER_PAGE = 20;
136
+ const ADMIN_PREFIX = {{ adminPrefix | dump | safe }};
137
+ const SOURCE_RESOURCE = {{ resource.slug | dump | safe }};
138
+ const PER_PAGE = 20;
138
139
  const DEBOUNCE_MS = 220;
139
140
 
140
141
  // Initialise every fk-widget — deferred so ui.js is guaranteed loaded.
@@ -150,6 +151,7 @@
150
151
  function initFKWidget(widget) {
151
152
  const name = widget.dataset.name;
152
153
  const resource = widget.dataset.resource;
154
+ const fkField = widget.dataset.fkField || name;
153
155
  const nullable = widget.dataset.nullable === 'true';
154
156
  const currentId = widget.dataset.currentId || '';
155
157
 
@@ -192,7 +194,7 @@
192
194
 
193
195
  // ── If there's a current value, resolve its label immediately ──────────
194
196
  if (currentId) {
195
- fetch(`${ADMIN_PREFIX}/api/${resource}/options?q=&page=1&limit=100`)
197
+ fetch(`${ADMIN_PREFIX}/api/${resource}/options?q=&page=1&limit=100&field=${fkField}&from=${SOURCE_RESOURCE}`)
196
198
  .then(r => r.json())
197
199
  .then(json => {
198
200
  const match = (json.data || []).find(r => String(r.id) === String(currentId));
@@ -295,6 +297,8 @@
295
297
  q: state.query,
296
298
  page: state.page,
297
299
  limit: PER_PAGE,
300
+ field: fkField,
301
+ from: SOURCE_RESOURCE,
298
302
  });
299
303
 
300
304
  fetch(`${ADMIN_PREFIX}/api/${resource}/options?${params}`)
@@ -25,7 +25,8 @@
25
25
  data-nullable="{{ 'true' if field.nullable else 'false' }}"
26
26
  data-required="{{ 'true' if not field.nullable else 'false' }}"
27
27
  data-current-id="{{ val }}"
28
- data-current-label="">
28
+ data-current-label=""
29
+ data-fk-field="{{ field.name }}">
29
30
  <input type="hidden"
30
31
  id="field-{{ field.name }}"
31
32
  name="{{ field.name }}"
@@ -0,0 +1,64 @@
1
+ {# ══════════════════════════════════════
2
+ SVG ICON SPRITE
3
+ ══════════════════════════════════════
4
+ Hidden SVG sprite — all icons referenced via <use href="#ic-name"/>.
5
+ To add a new icon: add a <symbol> here with id="ic-<name>".
6
+ All paths use stroke="currentColor" — colour is set via CSS.
7
+ ViewBox is always "0 0 24 24". Stroke-width 2, linecap/linejoin round.
8
+ #}
9
+ <svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
10
+
11
+ {# ── Navigation & Layout ── #}
12
+ <symbol id="ic-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
13
+ <symbol id="ic-list" viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
14
+ <symbol id="ic-home" viewBox="0 0 24 24"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1H5a1 1 0 01-1-1V9.5z"/><path d="M9 21V12h6v9"/></symbol>
15
+ <symbol id="ic-chevron-left" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></symbol>
16
+ <symbol id="ic-chevron-right"viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></symbol>
17
+ <symbol id="ic-chevron-down" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></symbol>
18
+ <symbol id="ic-arrow-left" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></symbol>
19
+
20
+ {# ── Data & Content ── #}
21
+ <symbol id="ic-table" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/></symbol>
22
+ <symbol id="ic-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></symbol>
23
+ <symbol id="ic-file" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></symbol>
24
+ <symbol id="ic-tag" viewBox="0 0 24 24"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none"/></symbol>
25
+ <symbol id="ic-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></symbol>
26
+ <symbol id="ic-activity" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></symbol>
27
+
28
+ {# ── Users & Auth ── #}
29
+ <symbol id="ic-users" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></symbol>
30
+ <symbol id="ic-user" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
31
+ <symbol id="ic-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
32
+ <symbol id="ic-shield-check" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></symbol>
33
+ <symbol id="ic-lock" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></symbol>
34
+ <symbol id="ic-log-out" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></symbol>
35
+
36
+ {# ── Actions ── #}
37
+ <symbol id="ic-plus" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></symbol>
38
+ <symbol id="ic-edit" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></symbol>
39
+ <symbol id="ic-trash" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></symbol>
40
+ <symbol id="ic-refresh" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></symbol>
41
+ <symbol id="ic-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
42
+ <symbol id="ic-upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></symbol>
43
+ <symbol id="ic-ban" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></symbol>
44
+
45
+ {# ── Status & Feedback ── #}
46
+ <symbol id="ic-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></symbol>
47
+ <symbol id="ic-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></symbol>
48
+ <symbol id="ic-alert-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></symbol>
49
+ <symbol id="ic-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></symbol>
50
+
51
+ {# ── Search & Filter ── #}
52
+ <symbol id="ic-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></symbol>
53
+ <symbol id="ic-filter" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></symbol>
54
+
55
+ {# ── Domain ── #}
56
+ <symbol id="ic-key" viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></symbol>
57
+ <symbol id="ic-calendar" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></symbol>
58
+ <symbol id="ic-credit-card" viewBox="0 0 24 24"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></symbol>
59
+ <symbol id="ic-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></symbol>
60
+ <symbol id="ic-more-vertical"viewBox="0 0 24 24"><circle cx="12" cy="5" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1" fill="currentColor" stroke="none"/></symbol>
61
+ <symbol id="ic-eye-off" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></symbol>
62
+ <symbol id="ic-eye" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></symbol>
63
+
64
+ </svg>