millas 0.1.2 → 0.1.4

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,145 @@
1
+ {% extends "layouts/base.njk" %}
2
+
3
+ {% block title %}{{ 'Edit' if isEdit else 'New' }} {{ resource.singular }}{% endblock %}
4
+ {% block topbar_title %}{{ resource.icon }} {{ 'Edit' if isEdit else 'New' }} {{ resource.singular }}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="breadcrumb">
8
+ <a href="{{ adminPrefix }}/">Dashboard</a>
9
+ <span class="breadcrumb-sep">›</span>
10
+ <a href="{{ adminPrefix }}/{{ resource.slug }}">{{ resource.label }}</a>
11
+ <span class="breadcrumb-sep">›</span>
12
+ <span>{{ 'Edit #' + record.id if isEdit else 'New' }}</span>
13
+ </div>
14
+
15
+ <div class="card" style="max-width:720px">
16
+ <div class="card-header">
17
+ <span class="card-title">{{ 'Edit ' + resource.singular + ' #' + record.id if isEdit else 'Create ' + resource.singular }}</span>
18
+ </div>
19
+ <div class="card-body">
20
+ <form method="POST" action="{{ formAction }}">
21
+ {% if isEdit %}
22
+ <input type="hidden" name="_method" value="PUT">
23
+ {% endif %}
24
+
25
+ {% if errors | length %}
26
+ <div class="alert alert-error" style="margin-bottom:20px">
27
+ <div>
28
+ <strong>Please fix the following errors:</strong>
29
+ <ul style="margin-top:6px;padding-left:16px">
30
+ {% for field, msgs in errors %}
31
+ <li>{{ msgs[0] }}</li>
32
+ {% endfor %}
33
+ </ul>
34
+ </div>
35
+ </div>
36
+ {% endif %}
37
+
38
+ <div class="form-grid">
39
+ {% for field in formFields %}
40
+ <div class="form-group {% if field.type == 'textarea' or field.type == 'json' %}full{% endif %}">
41
+ <label class="form-label">
42
+ {{ field.label }}
43
+ {% if field.required %}<span class="required">*</span>{% endif %}
44
+ </label>
45
+
46
+ {% set val = record[field.name] if record[field.name] is defined else '' %}
47
+ {% set hasError = errors[field.name] is defined %}
48
+
49
+ {% if field.type == 'select' and field.options %}
50
+ <select name="{{ field.name }}" class="form-control {% if hasError %}error{% endif %}">
51
+ <option value="">— Select —</option>
52
+ {% for opt in field.options %}
53
+ <option value="{{ opt }}" {% if val == opt %}selected{% endif %}>{{ opt }}</option>
54
+ {% endfor %}
55
+ </select>
56
+
57
+ {% elif field.type == 'boolean' %}
58
+ <select name="{{ field.name }}" class="form-control">
59
+ <option value="1" {% if val %}selected{% endif %}>Yes</option>
60
+ <option value="0" {% if not val %}selected{% endif %}>No</option>
61
+ </select>
62
+
63
+ {% elif field.type == 'textarea' %}
64
+ <textarea
65
+ name="{{ field.name }}"
66
+ class="form-control {% if hasError %}error{% endif %}"
67
+ placeholder="{{ field.placeholder or '' }}"
68
+ rows="4">{{ val }}</textarea>
69
+
70
+ {% elif field.type == 'json' %}
71
+ <textarea
72
+ name="{{ field.name }}"
73
+ class="form-control {% if hasError %}error{% endif %}"
74
+ placeholder='{"key": "value"}'
75
+ style="font-family:monospace;font-size:12px"
76
+ rows="5">{{ val | dump if val else '' }}</textarea>
77
+
78
+ {% elif field.type == 'password' %}
79
+ <input
80
+ type="password"
81
+ name="{{ field.name }}"
82
+ class="form-control {% if hasError %}error{% endif %}"
83
+ placeholder="{{ 'Leave blank to keep current' if isEdit else '' }}">
84
+
85
+ {% elif field.type == 'email' %}
86
+ <input
87
+ type="email"
88
+ name="{{ field.name }}"
89
+ value="{{ val }}"
90
+ class="form-control {% if hasError %}error{% endif %}"
91
+ placeholder="{{ field.placeholder or '' }}">
92
+
93
+ {% elif field.type == 'number' %}
94
+ <input
95
+ type="number"
96
+ name="{{ field.name }}"
97
+ value="{{ val }}"
98
+ class="form-control {% if hasError %}error{% endif %}"
99
+ placeholder="{{ field.placeholder or '0' }}">
100
+
101
+ {% elif field.type == 'date' %}
102
+ <input
103
+ type="date"
104
+ name="{{ field.name }}"
105
+ value="{{ val }}"
106
+ class="form-control {% if hasError %}error{% endif %}">
107
+
108
+ {% else %}
109
+ <input
110
+ type="text"
111
+ name="{{ field.name }}"
112
+ value="{{ val }}"
113
+ class="form-control {% if hasError %}error{% endif %}"
114
+ placeholder="{{ field.placeholder or '' }}">
115
+ {% endif %}
116
+
117
+ {% if hasError %}
118
+ <span class="form-error">{{ errors[field.name][0] }}</span>
119
+ {% elif field.help %}
120
+ <span class="form-help">{{ field.help }}</span>
121
+ {% endif %}
122
+ </div>
123
+ {% endfor %}
124
+ </div>
125
+
126
+ <div class="flex items-center gap-2" style="margin-top:24px;padding-top:20px;border-top:1px solid var(--border)">
127
+ <button type="submit" class="btn btn-primary">
128
+ {{ '💾 Save Changes' if isEdit else '+ Create ' + resource.singular }}
129
+ </button>
130
+ <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost">Cancel</a>
131
+ {% if isEdit and resource.canDelete %}
132
+ <button type="button" class="btn btn-danger" style="margin-left:auto"
133
+ onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ record.id }}/delete', '{{ resource.singular }} #{{ record.id }}')">
134
+ Delete
135
+ </button>
136
+ {% endif %}
137
+ </div>
138
+ </form>
139
+ </div>
140
+ </div>
141
+
142
+ <style>
143
+ .form-control.error { border-color: var(--danger); }
144
+ </style>
145
+ {% endblock %}
@@ -0,0 +1,164 @@
1
+ {% extends "layouts/base.njk" %}
2
+
3
+ {% block title %}{{ resource.label }}{% endblock %}
4
+ {% block topbar_title %}{{ resource.icon }} {{ resource.label }}{% endblock %}
5
+
6
+ {% block topbar_actions %}
7
+ {% if resource.canCreate %}
8
+ <a href="{{ adminPrefix }}/{{ resource.slug }}/create" class="btn btn-primary">
9
+ <span>+</span> New {{ resource.singular }}
10
+ </a>
11
+ {% endif %}
12
+ {% endblock %}
13
+
14
+ {% block content %}
15
+ <div class="breadcrumb">
16
+ <a href="{{ adminPrefix }}/">Dashboard</a>
17
+ <span class="breadcrumb-sep">›</span>
18
+ <span>{{ resource.label }}</span>
19
+ </div>
20
+
21
+ <div class="card">
22
+ {# ── 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">
26
+ {# Search #}
27
+ <form method="GET" action="{{ adminPrefix }}/{{ resource.slug }}" style="display:flex;gap:8px">
28
+ <input type="hidden" name="sort" value="{{ sort }}">
29
+ <input type="hidden" name="order" value="{{ order }}">
30
+ {% if filters | length %}
31
+ {% for key, val in activeFilters %}
32
+ <input type="hidden" name="filter[{{ key }}]" value="{{ val }}">
33
+ {% endfor %}
34
+ {% endif %}
35
+ <div class="search-wrap">
36
+ <span class="search-icon">🔍</span>
37
+ <input
38
+ type="text" name="search"
39
+ value="{{ search }}"
40
+ placeholder="Search..."
41
+ class="form-control search-input">
42
+ </div>
43
+ <button type="submit" class="btn btn-ghost">Search</button>
44
+ {% if search %}
45
+ <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost">✕ Clear</a>
46
+ {% endif %}
47
+ </form>
48
+ </div>
49
+ </div>
50
+
51
+ {# ── Filters row ── #}
52
+ {% 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">
55
+ <input type="hidden" name="search" value="{{ search }}">
56
+ <input type="hidden" name="sort" value="{{ sort }}">
57
+ <input type="hidden" name="order" value="{{ order }}">
58
+ {% 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>
61
+ {% if filter.type == 'select' %}
62
+ <select name="filter[{{ filter.name }}]" class="form-control" style="width:auto;min-width:120px" onchange="this.form.submit()">
63
+ <option value="">All</option>
64
+ {% for opt in filter.options %}
65
+ <option value="{{ opt }}" {% if activeFilters[filter.name] == opt %}selected{% endif %}>{{ opt }}</option>
66
+ {% endfor %}
67
+ </select>
68
+ {% elif filter.type == 'boolean' %}
69
+ <select name="filter[{{ filter.name }}]" class="form-control" style="width:auto;min-width:100px" onchange="this.form.submit()">
70
+ <option value="">All</option>
71
+ <option value="1" {% if activeFilters[filter.name] == '1' %}selected{% endif %}>Yes</option>
72
+ <option value="0" {% if activeFilters[filter.name] == '0' %}selected{% endif %}>No</option>
73
+ </select>
74
+ {% endif %}
75
+ </div>
76
+ {% 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 %}
80
+ </form>
81
+ </div>
82
+ {% endif %}
83
+
84
+ {# ── Table ── #}
85
+ <div class="table-wrap">
86
+ {% if rows | length %}
87
+ <table>
88
+ <thead>
89
+ <tr>
90
+ {% for field in listFields %}
91
+ <th class="{% if field.name in sortable %}sortable{% endif %} {% if sort == field.name %}sort-{{ order }}{% endif %}">
92
+ {% 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">
94
+ {{ field.label }}
95
+ </a>
96
+ {% else %}
97
+ {{ field.label }}
98
+ {% endif %}
99
+ </th>
100
+ {% endfor %}
101
+ {% if resource.canEdit or resource.canDelete %}
102
+ <th class="col-actions">Actions</th>
103
+ {% endif %}
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ {% for row in rows %}
108
+ <tr>
109
+ {% for field in listFields %}
110
+ <td>{{ row[field.name] | adminCell(field) | safe }}</td>
111
+ {% endfor %}
112
+ {% if resource.canEdit or resource.canDelete %}
113
+ <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 %}
121
+ </div>
122
+ </td>
123
+ {% endif %}
124
+ </tr>
125
+ {% endfor %}
126
+ </tbody>
127
+ </table>
128
+
129
+ {# ── Pagination ── #}
130
+ {% if lastPage > 1 %}
131
+ <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>
134
+ {% 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>
140
+ {% endif %}
141
+ {% 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>
144
+ <span class="page-info">
145
+ {{ (page - 1) * perPage + 1 }}–{{ [page * perPage, total] | min }} of {{ total }}
146
+ </span>
147
+ </div>
148
+ {% endif %}
149
+
150
+ {% else %}
151
+ <div class="empty-state">
152
+ <div class="empty-icon">{{ resource.icon }}</div>
153
+ <div class="empty-title">No {{ resource.label }} found</div>
154
+ <div class="empty-desc">
155
+ {% if search %}No results for "{{ search }}".{% else %}Get started by creating your first record.{% endif %}
156
+ </div>
157
+ {% if resource.canCreate %}
158
+ <a href="{{ adminPrefix }}/{{ resource.slug }}/create" class="btn btn-primary">+ New {{ resource.singular }}</a>
159
+ {% endif %}
160
+ </div>
161
+ {% endif %}
162
+ </div>
163
+ </div>
164
+ {% endblock %}
@@ -92,7 +92,38 @@ class Application {
92
92
  }
93
93
 
94
94
  /**
95
- * Bind all routes onto the Express app + mount error handlers.
95
+ * Bind all routes onto the Express app.
96
+ * Does NOT add 404/error fallbacks — call mountFallbacks() after
97
+ * registering any extra middleware (e.g. Admin panel).
98
+ *
99
+ * Typical usage:
100
+ * app.mountRoutes();
101
+ * Admin.mount(expressApp); // admin routes added here
102
+ * app.mountFallbacks(); // 404 + error handler last
103
+ *
104
+ * Or use app.mount() which does all three in one call.
105
+ */
106
+ mountRoutes() {
107
+ this._router = new Router(this._express, this._route.getRegistry(), this._mwRegistry);
108
+ this._router.mountRoutes();
109
+ return this;
110
+ }
111
+
112
+ /**
113
+ * Add 404 + global error handlers. Must be called LAST.
114
+ */
115
+ mountFallbacks() {
116
+ if (!this._router) {
117
+ this._router = new Router(this._express, this._route.getRegistry(), this._mwRegistry);
118
+ }
119
+ this._router.mountFallbacks();
120
+ return this;
121
+ }
122
+
123
+ /**
124
+ * Bind routes + add 404/error handlers in one call.
125
+ * Use this when NOT using the Admin panel, or when Admin
126
+ * is mounted before this call.
96
127
  */
97
128
  mount() {
98
129
  const router = new Router(this._express, this._route.getRegistry(), this._mwRegistry);