polyadmin 0.1.0b1__py3-none-any.whl
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.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
{# Design tokens + frontend runtime.
|
|
2
|
+
|
|
3
|
+
The variables below are shadcn/ui's stock Zinc theme, and every class
|
|
4
|
+
string in polyadmin/ui.py resolves to them rather than to a literal
|
|
5
|
+
Tailwind palette -- so restyling the admin, or flipping it to dark, is
|
|
6
|
+
a change to these variables and nothing else.
|
|
7
|
+
|
|
8
|
+
Script order is load-order-critical, top to bottom:
|
|
9
|
+
|
|
10
|
+
1. the dark-class script must run synchronously before paint, or a
|
|
11
|
+
dark-mode reload flashes the light palette for a frame;
|
|
12
|
+
2. the Tailwind CDN script must load *before* `tailwind.config` is
|
|
13
|
+
assigned -- backwards from what you would guess. That script is
|
|
14
|
+
what defines the global `tailwind`, so assigning to it first
|
|
15
|
+
throws a ReferenceError the browser swallows, and every
|
|
16
|
+
token-based utility then resolves to nothing;
|
|
17
|
+
3. Alpine plugins must load before Alpine core, which initializes
|
|
18
|
+
on load and only sees directives registered by then.
|
|
19
|
+
|
|
20
|
+
Everything is CDN-loaded: there is no frontend build step. #}
|
|
21
|
+
<script>
|
|
22
|
+
// (1) Pre-paint theme resolution -- see the note above.
|
|
23
|
+
(function () {
|
|
24
|
+
try {
|
|
25
|
+
var stored = localStorage.getItem("polyadmin-theme");
|
|
26
|
+
var dark = stored
|
|
27
|
+
? stored === "dark"
|
|
28
|
+
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
29
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
/* localStorage unavailable (private mode, file://) -- stay light. */
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
37
|
+
<script>
|
|
38
|
+
// (2) Tailwind Play CDN configuration -- the CDN script above must
|
|
39
|
+
// load first, since it's what defines the `tailwind` global this
|
|
40
|
+
// assigns onto (see the note above). `<alpha-value>` is what lets the
|
|
41
|
+
// opacity modifiers the shadcn class lists rely on -- bg-primary/90,
|
|
42
|
+
// bg-muted/50, border-destructive/50 -- resolve against a CSS
|
|
43
|
+
// variable holding a bare HSL triplet.
|
|
44
|
+
tailwind.config = {
|
|
45
|
+
darkMode: "class",
|
|
46
|
+
theme: {
|
|
47
|
+
extend: {
|
|
48
|
+
fontFamily: {
|
|
49
|
+
sans: [
|
|
50
|
+
"ui-sans-serif", "system-ui", "-apple-system", "Segoe UI",
|
|
51
|
+
"Roboto", "Helvetica Neue", "Arial", "sans-serif",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
borderRadius: {
|
|
55
|
+
lg: "var(--radius)",
|
|
56
|
+
md: "calc(var(--radius) - 2px)",
|
|
57
|
+
sm: "calc(var(--radius) - 4px)",
|
|
58
|
+
},
|
|
59
|
+
colors: {
|
|
60
|
+
border: "hsl(var(--border) / <alpha-value>)",
|
|
61
|
+
input: "hsl(var(--input) / <alpha-value>)",
|
|
62
|
+
ring: "hsl(var(--ring) / <alpha-value>)",
|
|
63
|
+
background: "hsl(var(--background) / <alpha-value>)",
|
|
64
|
+
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
|
65
|
+
primary: {
|
|
66
|
+
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
|
67
|
+
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
|
|
68
|
+
},
|
|
69
|
+
secondary: {
|
|
70
|
+
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
|
71
|
+
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
|
|
72
|
+
},
|
|
73
|
+
destructive: {
|
|
74
|
+
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
|
75
|
+
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
|
|
76
|
+
},
|
|
77
|
+
muted: {
|
|
78
|
+
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
|
79
|
+
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
|
|
80
|
+
},
|
|
81
|
+
accent: {
|
|
82
|
+
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
|
83
|
+
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
|
|
84
|
+
},
|
|
85
|
+
popover: {
|
|
86
|
+
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
|
87
|
+
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
|
|
88
|
+
},
|
|
89
|
+
card: {
|
|
90
|
+
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
|
91
|
+
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
|
|
92
|
+
},
|
|
93
|
+
chart: {
|
|
94
|
+
1: "hsl(var(--chart-1) / <alpha-value>)",
|
|
95
|
+
2: "hsl(var(--chart-2) / <alpha-value>)",
|
|
96
|
+
3: "hsl(var(--chart-3) / <alpha-value>)",
|
|
97
|
+
4: "hsl(var(--chart-4) / <alpha-value>)",
|
|
98
|
+
5: "hsl(var(--chart-5) / <alpha-value>)",
|
|
99
|
+
6: "hsl(var(--chart-6) / <alpha-value>)",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
{# (3) Alpine plugins, then Alpine core, then HTMX. SortableJS is
|
|
108
|
+
deferred so it is ready before any x-init calling Sortable.create,
|
|
109
|
+
and is the one non-Alpine dependency: drag-and-drop has no small
|
|
110
|
+
CSS-only equivalent. #}
|
|
111
|
+
<script defer src="https://unpkg.com/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>
|
|
112
|
+
<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
|
|
113
|
+
<script defer src="https://unpkg.com/@alpinejs/anchor@3.x.x/dist/cdn.min.js"></script>
|
|
114
|
+
<script defer src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
|
|
115
|
+
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
116
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
117
|
+
<script>
|
|
118
|
+
// One listener covers every mutating hx-* attribute in the admin --
|
|
119
|
+
// including the bodyless hx-deletes, which have no form field to
|
|
120
|
+
// carry a token -- and any added later.
|
|
121
|
+
//
|
|
122
|
+
// On `document`, not `document.body`: this file is included from
|
|
123
|
+
// base.html's <head>, where document.body is still null. htmx's
|
|
124
|
+
// events bubble, so the document catches them all either way.
|
|
125
|
+
document.addEventListener("htmx:configRequest", function (evt) {
|
|
126
|
+
if (/^(GET|HEAD|OPTIONS|TRACE)$/i.test(evt.detail.verb)) return;
|
|
127
|
+
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
128
|
+
if (meta) evt.detail.headers["X-CSRF-Token"] = meta.content;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Dates and decimals are rendered as ISO values and formatted here, in
|
|
132
|
+
// the page's locale, so both implementations display them identically.
|
|
133
|
+
// A date-only value is built from its parts: new Date("YYYY-MM-DD")
|
|
134
|
+
// parses as UTC midnight, the previous day west of Greenwich. The
|
|
135
|
+
// Date constructor maps years 0-99 onto 1900-1999, so the year is set
|
|
136
|
+
// again with setFullYear, which does not.
|
|
137
|
+
//
|
|
138
|
+
// One Intl formatter per format, built on first use. Each value is
|
|
139
|
+
// formatted on its own: an unknown lang or an option out of range
|
|
140
|
+
// throws, and that leaves the one value in its ISO form rather than
|
|
141
|
+
// aborting the page. A decimal goes to NumberFormat as its string, which
|
|
142
|
+
// engines that support it format exactly (a Number would round past 15
|
|
143
|
+
// or so digits); older ones convert it to a Number themselves.
|
|
144
|
+
window.polyadminFormat = function (root) {
|
|
145
|
+
var lang = document.documentElement.lang || undefined, cache = {};
|
|
146
|
+
function formatter(key, make) {
|
|
147
|
+
if (!Object.prototype.hasOwnProperty.call(cache, key)) cache[key] = make();
|
|
148
|
+
return cache[key];
|
|
149
|
+
}
|
|
150
|
+
root.querySelectorAll("time[data-format]").forEach(function (el) {
|
|
151
|
+
try {
|
|
152
|
+
var iso = el.getAttribute("datetime"), d, m;
|
|
153
|
+
if (el.dataset.format === "date") {
|
|
154
|
+
m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
155
|
+
if (!m) return;
|
|
156
|
+
d = new Date(+m[1], +m[2] - 1, +m[3]);
|
|
157
|
+
d.setFullYear(+m[1], +m[2] - 1, +m[3]);
|
|
158
|
+
el.textContent = formatter("date", function () {
|
|
159
|
+
return new Intl.DateTimeFormat(lang, { dateStyle: "medium" });
|
|
160
|
+
}).format(d);
|
|
161
|
+
} else {
|
|
162
|
+
m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(iso);
|
|
163
|
+
if (!m) return;
|
|
164
|
+
if (/(Z|[+-]\d{2}:\d{2})$/.test(iso)) {
|
|
165
|
+
d = new Date(iso);
|
|
166
|
+
} else {
|
|
167
|
+
d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0));
|
|
168
|
+
d.setFullYear(+m[1], +m[2] - 1, +m[3]);
|
|
169
|
+
}
|
|
170
|
+
el.textContent = formatter("datetime", function () {
|
|
171
|
+
return new Intl.DateTimeFormat(lang, { dateStyle: "medium", timeStyle: "short" });
|
|
172
|
+
}).format(d);
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// Keep the ISO text.
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
root.querySelectorAll('[data-format="decimal"]').forEach(function (el) {
|
|
179
|
+
try {
|
|
180
|
+
var raw = el.dataset.value;
|
|
181
|
+
if (raw === "" || !isFinite(Number(raw))) return;
|
|
182
|
+
// NumberFormat accepts at most 20 fraction digits.
|
|
183
|
+
var digits = Math.min((raw.split(".")[1] || "").length, 20);
|
|
184
|
+
el.textContent = formatter("decimal" + digits, function () {
|
|
185
|
+
return new Intl.NumberFormat(lang, { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
186
|
+
}).format(raw);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
// Keep the raw text.
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// prepopulated_fields (docs/model-admin.md): fill a field from others as
|
|
194
|
+
// they are typed. Create forms only -- the server omits the attribute on
|
|
195
|
+
// an edit form, because an existing record's slug is a real identifier.
|
|
196
|
+
//
|
|
197
|
+
// The slug rule mirrors the server's Slugify: transliterate, lowercase,
|
|
198
|
+
// collapse the rest into single hyphens. The Cyrillic table is the same
|
|
199
|
+
// data, kept here so the browser needs no round trip.
|
|
200
|
+
var CYRILLIC = {
|
|
201
|
+
"а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ё":"yo","ж":"zh","з":"z",
|
|
202
|
+
"и":"i","й":"y","к":"k","л":"l","м":"m","н":"n","о":"o","п":"p","р":"r",
|
|
203
|
+
"с":"s","т":"t","у":"u","ф":"f","х":"kh","ц":"ts","ч":"ch","ш":"sh",
|
|
204
|
+
"щ":"shch","ъ":"","ы":"y","ь":"","э":"e","ю":"yu","я":"ya"
|
|
205
|
+
};
|
|
206
|
+
function polyadminSlugify(value, keepUnicode) {
|
|
207
|
+
var mapped = "";
|
|
208
|
+
for (var i = 0; i < value.length; i++) {
|
|
209
|
+
var ch = value[i], lower = ch.toLowerCase();
|
|
210
|
+
mapped += (!keepUnicode && CYRILLIC.hasOwnProperty(lower)) ? CYRILLIC[lower] : ch;
|
|
211
|
+
}
|
|
212
|
+
mapped = mapped.toLowerCase();
|
|
213
|
+
if (!keepUnicode) {
|
|
214
|
+
// Decompose, drop the combining marks an accent decomposed into, then
|
|
215
|
+
// drop whatever is still not ASCII.
|
|
216
|
+
mapped = mapped.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^\x00-\x7F]/g, "");
|
|
217
|
+
}
|
|
218
|
+
var pattern = keepUnicode ? /[^\p{L}\p{N}]+/gu : /[^a-z0-9]+/g;
|
|
219
|
+
return mapped.replace(pattern, "-").replace(/^-+|-+$/g, "");
|
|
220
|
+
}
|
|
221
|
+
window.polyadminPrepopulate = function (root) {
|
|
222
|
+
root.querySelectorAll("form[data-prepopulated]").forEach(function (form) {
|
|
223
|
+
var spec;
|
|
224
|
+
try {
|
|
225
|
+
spec = JSON.parse(form.dataset.prepopulated);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
Object.keys(spec).forEach(function (name) {
|
|
230
|
+
var target = form.querySelector('[name="' + name + '"]');
|
|
231
|
+
if (!target || target.dataset.prepopulateBound) return;
|
|
232
|
+
target.dataset.prepopulateBound = "1";
|
|
233
|
+
// Typing in the target detaches it for the rest of the form's life,
|
|
234
|
+
// and a value already there is never overwritten.
|
|
235
|
+
var detached = target.value !== "";
|
|
236
|
+
target.addEventListener("input", function () { detached = true; });
|
|
237
|
+
var sources = (spec[name].from || []).map(function (from) {
|
|
238
|
+
return form.querySelector('[name="' + from + '"]');
|
|
239
|
+
}).filter(Boolean);
|
|
240
|
+
var fill = function () {
|
|
241
|
+
if (detached) return;
|
|
242
|
+
var parts = sources.map(function (el) { return el.value; }).filter(Boolean);
|
|
243
|
+
target.value = polyadminSlugify(parts.join(" "), !!spec[name].unicode);
|
|
244
|
+
};
|
|
245
|
+
sources.forEach(function (el) { el.addEventListener("input", fill); });
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
document.addEventListener("DOMContentLoaded", function () { window.polyadminFormat(document); window.polyadminPrepopulate(document); });
|
|
250
|
+
document.addEventListener("htmx:load", function (evt) { window.polyadminFormat(evt.detail.elt); window.polyadminPrepopulate(evt.detail.elt); });
|
|
251
|
+
</script>
|
|
252
|
+
|
|
253
|
+
<style>
|
|
254
|
+
/* shadcn/ui's stock "Zinc" palette, as bare HSL triplets. */
|
|
255
|
+
:root {
|
|
256
|
+
--background: 0 0% 100%;
|
|
257
|
+
--foreground: 240 10% 3.9%;
|
|
258
|
+
--card: 0 0% 100%;
|
|
259
|
+
--card-foreground: 240 10% 3.9%;
|
|
260
|
+
--popover: 0 0% 100%;
|
|
261
|
+
--popover-foreground: 240 10% 3.9%;
|
|
262
|
+
--primary: 240 5.9% 10%;
|
|
263
|
+
--primary-foreground: 0 0% 98%;
|
|
264
|
+
--secondary: 240 4.8% 95.9%;
|
|
265
|
+
--secondary-foreground: 240 5.9% 10%;
|
|
266
|
+
--muted: 240 4.8% 95.9%;
|
|
267
|
+
--muted-foreground: 240 3.8% 46.1%;
|
|
268
|
+
--accent: 240 4.8% 95.9%;
|
|
269
|
+
--accent-foreground: 240 5.9% 10%;
|
|
270
|
+
--destructive: 0 72.2% 50.6%;
|
|
271
|
+
--destructive-foreground: 0 0% 98%;
|
|
272
|
+
--border: 240 5.9% 90%;
|
|
273
|
+
--input: 240 5.9% 90%;
|
|
274
|
+
--ring: 240 5.9% 10%;
|
|
275
|
+
--chart-1: 12 76% 61%;
|
|
276
|
+
--chart-2: 173 58% 39%;
|
|
277
|
+
--chart-3: 197 37% 44%;
|
|
278
|
+
--chart-4: 43 74% 66%;
|
|
279
|
+
--chart-5: 27 87% 67%;
|
|
280
|
+
--chart-6: 262 60% 62%;
|
|
281
|
+
--radius: 0.5rem;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.dark {
|
|
285
|
+
--background: 240 10% 3.9%;
|
|
286
|
+
--foreground: 0 0% 98%;
|
|
287
|
+
--card: 240 10% 5.9%;
|
|
288
|
+
--card-foreground: 0 0% 98%;
|
|
289
|
+
--popover: 240 10% 5.9%;
|
|
290
|
+
--popover-foreground: 0 0% 98%;
|
|
291
|
+
--primary: 0 0% 98%;
|
|
292
|
+
--primary-foreground: 240 5.9% 10%;
|
|
293
|
+
--secondary: 240 3.7% 15.9%;
|
|
294
|
+
--secondary-foreground: 0 0% 98%;
|
|
295
|
+
--muted: 240 3.7% 15.9%;
|
|
296
|
+
--muted-foreground: 240 5% 64.9%;
|
|
297
|
+
--accent: 240 3.7% 15.9%;
|
|
298
|
+
--accent-foreground: 0 0% 98%;
|
|
299
|
+
--destructive: 0 62.8% 45%;
|
|
300
|
+
--destructive-foreground: 0 0% 98%;
|
|
301
|
+
--border: 240 3.7% 15.9%;
|
|
302
|
+
--input: 240 3.7% 15.9%;
|
|
303
|
+
--ring: 240 4.9% 83.9%;
|
|
304
|
+
--chart-1: 220 70% 50%;
|
|
305
|
+
--chart-2: 160 60% 45%;
|
|
306
|
+
--chart-3: 30 80% 55%;
|
|
307
|
+
--chart-4: 280 65% 60%;
|
|
308
|
+
--chart-5: 340 75% 55%;
|
|
309
|
+
--chart-6: 190 70% 45%;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
[x-cloak] { display: none !important; }
|
|
313
|
+
|
|
314
|
+
/* Alpine's x-collapse animates height; without this the accordion
|
|
315
|
+
content jumps its scrollbar in during the transition. */
|
|
316
|
+
[x-collapse] { overflow: hidden; }
|
|
317
|
+
|
|
318
|
+
/* Checkbox check glyph (lucide Check, the icon shadcn/ui's Checkbox
|
|
319
|
+
uses). It lives here rather than in ui("checkbox") for two reasons:
|
|
320
|
+
a native <input> cannot carry a ::before/::after reliably, so the
|
|
321
|
+
mark has to be a background image; and its colour is
|
|
322
|
+
--primary-foreground, which flips with the theme and cannot be
|
|
323
|
+
interpolated into a Tailwind arbitrary value. Hence one rule per
|
|
324
|
+
theme, matching the two token values in this file. */
|
|
325
|
+
.ui-checkbox:checked {
|
|
326
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fafafa' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
|
|
327
|
+
background-size: 0.7rem;
|
|
328
|
+
background-position: center;
|
|
329
|
+
background-repeat: no-repeat;
|
|
330
|
+
}
|
|
331
|
+
/* ScrollArea (ui("scroll-area")). Radix draws a custom scrollbar over
|
|
332
|
+
a hidden native one; this restyles the native one instead, which
|
|
333
|
+
needs real CSS -- Tailwind has no scrollbar utilities without a
|
|
334
|
+
plugin. Thin, token-coloured, and only visible where it is needed. */
|
|
335
|
+
.ui-scroll-area {
|
|
336
|
+
scrollbar-width: thin;
|
|
337
|
+
scrollbar-color: hsl(var(--border)) transparent;
|
|
338
|
+
}
|
|
339
|
+
.ui-scroll-area::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
340
|
+
.ui-scroll-area::-webkit-scrollbar-track { background: transparent; }
|
|
341
|
+
.ui-scroll-area::-webkit-scrollbar-thumb {
|
|
342
|
+
background-color: hsl(var(--border));
|
|
343
|
+
border-radius: 9999px;
|
|
344
|
+
}
|
|
345
|
+
.ui-scroll-area:hover::-webkit-scrollbar-thumb {
|
|
346
|
+
background-color: hsl(var(--muted-foreground) / 0.5);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.dark .ui-checkbox:checked {
|
|
350
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2318181b' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
|
|
351
|
+
}
|
|
352
|
+
</style>
|
|
353
|
+
|
|
354
|
+
<script>
|
|
355
|
+
// The theme store backing the header's toggle (ui/theme-toggle).
|
|
356
|
+
// Registered on alpine:init so it exists before any component that
|
|
357
|
+
// reads $store.theme is initialized.
|
|
358
|
+
document.addEventListener("alpine:init", function () {
|
|
359
|
+
Alpine.store("theme", {
|
|
360
|
+
dark: document.documentElement.classList.contains("dark"),
|
|
361
|
+
toggle() {
|
|
362
|
+
this.dark = !this.dark;
|
|
363
|
+
document.documentElement.classList.toggle("dark", this.dark);
|
|
364
|
+
try {
|
|
365
|
+
localStorage.setItem("polyadmin-theme", this.dark ? "dark" : "light");
|
|
366
|
+
} catch (e) {
|
|
367
|
+
/* Preference just doesn't persist; the toggle still works. */
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div class="space-y-2">
|
|
2
|
+
{% for label, value, percent in widget_data.series %}
|
|
3
|
+
<div>
|
|
4
|
+
<div class="mb-0.5 flex justify-between text-xs text-muted-foreground">
|
|
5
|
+
<span>{{ label }}</span><span>{{ value }}</span>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="{{ ui('widget', 'bar-track') }}">
|
|
8
|
+
<div class="{{ ui('widget', 'bar-fill') }}" style="width: {{ percent }}%"></div>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
{% endfor %}
|
|
12
|
+
{% if not widget_data.series %}
|
|
13
|
+
<p class="text-sm text-muted-foreground">{{ _("No data.") }}</p>
|
|
14
|
+
{% endif %}
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{# Share-of-total breakdown as a dependency-free SVG ring -- stacked
|
|
2
|
+
<circle> arcs plus a legend, the same no-charting-library stance as
|
|
3
|
+
widgets/chart.html.
|
|
4
|
+
|
|
5
|
+
Hovering a slice or its legend row highlights both and swaps the
|
|
6
|
+
ring's centre label. `active` is compared against a plain loop index,
|
|
7
|
+
never a label, so nothing dynamic is interpolated into the Alpine
|
|
8
|
+
expression.
|
|
9
|
+
|
|
10
|
+
The legend row's highlight stops at the card's content edge rather
|
|
11
|
+
than bleeding into its padding: the card body is a bounded scroll box
|
|
12
|
+
(dashboard.html), so that bleed was 8px of horizontal overflow. #}
|
|
13
|
+
<div x-data="{ active: null }" class="flex flex-col items-center gap-6 sm:flex-row">
|
|
14
|
+
<div class="relative h-36 w-36 shrink-0">
|
|
15
|
+
<svg viewBox="0 0 36 36" class="h-36 w-36">
|
|
16
|
+
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="currentColor" stroke-width="3" class="text-muted"></circle>
|
|
17
|
+
{% for slice in widget_data.slices %}
|
|
18
|
+
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="currentColor" stroke-width="3"
|
|
19
|
+
stroke-dasharray="{{ slice.percent }} {{ 100 - slice.percent }}"
|
|
20
|
+
stroke-dashoffset="{{ slice.dash_offset }}"
|
|
21
|
+
class="text-{{ slice.color }} transition-opacity duration-150"
|
|
22
|
+
:class="active !== null && active !== {{ loop.index0 }} ? 'opacity-30' : ''"
|
|
23
|
+
@mouseenter="active = {{ loop.index0 }}" @mouseleave="active = null"></circle>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
</svg>
|
|
26
|
+
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
|
27
|
+
<template x-if="active === null">
|
|
28
|
+
<div>
|
|
29
|
+
<p class="text-lg font-semibold text-foreground">{{ widget_data.total }}</p>
|
|
30
|
+
<p class="text-[11px] text-muted-foreground">{{ _("Total") }}</p>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
{% for slice in widget_data.slices %}
|
|
34
|
+
<template x-if="active === {{ loop.index0 }}">
|
|
35
|
+
<div>
|
|
36
|
+
<p class="text-lg font-semibold text-foreground">{{ slice.percent }}%</p>
|
|
37
|
+
<p class="text-[11px] text-muted-foreground">{{ slice.label }}</p>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
{% endfor %}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<ul class="w-full space-y-1 text-sm">
|
|
44
|
+
{% for slice in widget_data.slices %}
|
|
45
|
+
<li class="flex items-center justify-between gap-2 rounded-md px-2 py-1 transition-colors"
|
|
46
|
+
:class="active === {{ loop.index0 }} ? 'bg-muted/50' : ''"
|
|
47
|
+
@mouseenter="active = {{ loop.index0 }}" @mouseleave="active = null">
|
|
48
|
+
<span class="flex items-center gap-2 text-foreground">
|
|
49
|
+
<span class="h-2.5 w-2.5 shrink-0 rounded-full bg-{{ slice.color }}"></span>
|
|
50
|
+
{{ slice.label }}
|
|
51
|
+
</span>
|
|
52
|
+
<span class="text-muted-foreground">{{ slice.value }} <span class="text-muted-foreground">· {{ slice.percent }}%</span></span>
|
|
53
|
+
</li>
|
|
54
|
+
{% endfor %}
|
|
55
|
+
{% if not widget_data.slices %}
|
|
56
|
+
<li class="text-sm text-muted-foreground">{{ _("No data.") }}</li>
|
|
57
|
+
{% endif %}
|
|
58
|
+
</ul>
|
|
59
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<p class="{{ ui('text', 'metric') }}">{{ widget_data.value }}</p>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div class="mb-1 flex justify-between text-sm text-muted-foreground">
|
|
2
|
+
<span>{{ widget_data.value }} / {{ widget_data.target }}</span>
|
|
3
|
+
<span>{{ widget_data.percent }}%</span>
|
|
4
|
+
</div>
|
|
5
|
+
<div class="{{ ui('widget', 'bar-track') }}">
|
|
6
|
+
<div class="{{ ui('widget', 'bar-fill') }}" style="width: {{ widget_data.percent }}%"></div>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{# Headline number plus period-over-period change.
|
|
2
|
+
|
|
3
|
+
Green/red here are deliberate, unlike in widgets/donut.html which
|
|
4
|
+
avoids them: a delta really is a good/bad signal, so it reads in the
|
|
5
|
+
same colours as a success/danger toast. The arrow carries the
|
|
6
|
+
direction too, so the meaning survives for anyone who cannot separate
|
|
7
|
+
the two hues. #}
|
|
8
|
+
{% from "admin/components/icons.html" import icon %}
|
|
9
|
+
<div class="flex items-end justify-between gap-3">
|
|
10
|
+
<p class="{{ ui('text', 'metric') }}">{{ widget_data.value }}</p>
|
|
11
|
+
{% if widget_data.direction == "up" %}
|
|
12
|
+
<span class="inline-flex shrink-0 items-center gap-0.5 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
|
13
|
+
{{ icon("arrow-up", "h-4 w-4") }}{{ widget_data.delta }}%
|
|
14
|
+
</span>
|
|
15
|
+
{% elif widget_data.direction == "down" %}
|
|
16
|
+
<span class="inline-flex shrink-0 items-center gap-0.5 text-sm font-medium text-destructive">
|
|
17
|
+
{{ icon("arrow-down", "h-4 w-4") }}{{ widget_data.delta }}%
|
|
18
|
+
</span>
|
|
19
|
+
{% else %}
|
|
20
|
+
<span class="shrink-0 text-sm font-medium text-muted-foreground">{{ _("no change") }}</span>
|
|
21
|
+
{% endif %}
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{# The card's own body (dashboard.html) is the scroll box, so this
|
|
2
|
+
template adds no overflow container of its own -- a nested one would
|
|
3
|
+
put a second scrollbar inside the card. The header sticks to the top of
|
|
4
|
+
that box, so the columns stay readable while a long widget scrolls.
|
|
5
|
+
|
|
6
|
+
That box scrolls vertically only, so the cells wrap (and break a long
|
|
7
|
+
unbroken value, such as an address) instead of holding their line and
|
|
8
|
+
pushing the card sideways. #}
|
|
9
|
+
<table class="min-w-full text-sm">
|
|
10
|
+
<thead class="sticky top-0 z-10 bg-card">
|
|
11
|
+
<tr class="border-b border-border">
|
|
12
|
+
{% for column in widget_data.columns %}
|
|
13
|
+
<th class="{{ ui('table', 'th-compact') }}">{{ _(column) }}</th>
|
|
14
|
+
{% endfor %}
|
|
15
|
+
</tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody class="divide-y divide-border">
|
|
18
|
+
{% for row in widget_data.rows %}
|
|
19
|
+
<tr class="hover:bg-muted/50">
|
|
20
|
+
{% for column in widget_data.columns %}
|
|
21
|
+
<td class="px-2 py-1.5 text-foreground break-words">{{ row[column] }}</td>
|
|
22
|
+
{% endfor %}
|
|
23
|
+
</tr>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
</tbody>
|
|
26
|
+
</table>
|
|
27
|
+
{% if not widget_data.rows %}
|
|
28
|
+
<p class="text-sm text-muted-foreground">{{ _("No data.") }}</p>
|
|
29
|
+
{% endif %}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{# Container widget: several widgets in one card, one visible at a
|
|
2
|
+
time.
|
|
3
|
+
|
|
4
|
+
Each panel is rendered through the same `{% include widget.template %}`
|
|
5
|
+
the dashboard uses for a top-level widget. The `{% with %}` scope is
|
|
6
|
+
what makes that safe to nest: it rebinds `widget_data` for the
|
|
7
|
+
included template only, so the outer widget's own data is still
|
|
8
|
+
intact for the next panel (and for a Tabs inside a Tabs).
|
|
9
|
+
|
|
10
|
+
Only panels after the first carry x-cloak, so with Alpine still
|
|
11
|
+
loading the first stays visible and the rest hidden: the card
|
|
12
|
+
degrades to its default tab rather than to everything at once.
|
|
13
|
+
|
|
14
|
+
`active` is compared against a loop index, never a label, so nothing
|
|
15
|
+
dynamic reaches an Alpine expression. #}
|
|
16
|
+
<div x-data="{ active: 0 }">
|
|
17
|
+
{% if widget_data.panels %}
|
|
18
|
+
<div class="{{ ui('tabs', 'underline-list') }}" role="tablist">
|
|
19
|
+
{% for panel in widget_data.panels %}
|
|
20
|
+
<button type="button" role="tab" @click="active = {{ loop.index0 }}"
|
|
21
|
+
:aria-selected="active === {{ loop.index0 }}"
|
|
22
|
+
:class="active === {{ loop.index0 }} ? '{{ ui('tabs', 'underline-trigger-on') }}' : '{{ ui('tabs', 'underline-trigger-off') }}'"
|
|
23
|
+
class="{{ ui('tabs', 'underline-trigger') }}">{{ _(panel.label) }}</button>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
</div>
|
|
26
|
+
{% for panel in widget_data.panels %}
|
|
27
|
+
<div role="tabpanel" x-show="active === {{ loop.index0 }}"{% if not loop.first %} x-cloak{% endif %}>
|
|
28
|
+
{% with widget_data = panel.widget.get_data() %}{% include panel.widget.template %}{% endwith %}
|
|
29
|
+
</div>
|
|
30
|
+
{% endfor %}
|
|
31
|
+
{% else %}
|
|
32
|
+
<p class="text-sm text-muted-foreground">{{ _("No panels configured.") }}</p>
|
|
33
|
+
{% endif %}
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{# Dated-event feed drawn as a rail of dots.
|
|
2
|
+
|
|
3
|
+
The dots are absolutely positioned against the <ol>, which owns the
|
|
4
|
+
rail (border-l); -left-[4.5px] centers a 2-unit (8px) dot over that
|
|
5
|
+
1px border. #}
|
|
6
|
+
{% if widget_data.entries %}
|
|
7
|
+
<ol class="relative ml-1 space-y-4 border-l border-border">
|
|
8
|
+
{% for entry in widget_data.entries %}
|
|
9
|
+
<li class="ml-4">
|
|
10
|
+
<span class="absolute -left-[4.5px] mt-1.5 h-2 w-2 rounded-full bg-border ring-2 ring-background"></span>
|
|
11
|
+
{% if entry.time %}<time class="text-xs text-muted-foreground">{{ entry.time }}</time>{% endif %}
|
|
12
|
+
<p class="text-sm font-medium text-foreground">{{ entry.title }}</p>
|
|
13
|
+
{% if entry.description %}
|
|
14
|
+
<p class="mt-0.5 text-sm text-muted-foreground">{{ entry.description }}</p>
|
|
15
|
+
{% endif %}
|
|
16
|
+
</li>
|
|
17
|
+
{% endfor %}
|
|
18
|
+
</ol>
|
|
19
|
+
{% else %}
|
|
20
|
+
<p class="text-sm text-muted-foreground">{{ _("No recent activity.") }}</p>
|
|
21
|
+
{% endif %}
|