plutonium 0.52.0 → 0.53.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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-resource/SKILL.md +6 -4
- data/.claude/skills/plutonium-tenancy/SKILL.md +9 -4
- data/.claude/skills/plutonium-ui/SKILL.md +29 -5
- data/CHANGELOG.md +16 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +257 -11
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +39 -39
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +2 -1
- data/docs/.vitepress/config.ts +1 -0
- data/docs/guides/authentication.md +1 -1
- data/docs/guides/custom-actions.md +2 -1
- data/docs/guides/customizing-ui.md +6 -5
- data/docs/guides/multi-tenancy.md +6 -6
- data/docs/guides/theming.md +1 -1
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/reference/auth/accounts.md +1 -1
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/configuration.md +61 -0
- data/docs/reference/resource/actions.md +2 -1
- data/docs/reference/resource/definition.md +4 -3
- data/docs/reference/tenancy/entity-scoping.md +12 -13
- data/docs/reference/ui/components.md +53 -0
- data/docs/reference/ui/forms.md +1 -1
- data/docs/reference/ui/pages.md +6 -5
- data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
- data/lib/plutonium/action/base.rb +43 -63
- data/lib/plutonium/configuration.rb +7 -0
- data/lib/plutonium/definition/actions.rb +10 -11
- data/lib/plutonium/definition/base.rb +29 -0
- data/lib/plutonium/helpers/assets_helper.rb +0 -30
- data/lib/plutonium/helpers/content_helper.rb +0 -44
- data/lib/plutonium/helpers/display_helper.rb +0 -62
- data/lib/plutonium/helpers/turbo_helper.rb +0 -4
- data/lib/plutonium/helpers.rb +0 -2
- data/lib/plutonium/resource/definition.rb +0 -42
- data/lib/plutonium/ui/action_button.rb +4 -3
- data/lib/plutonium/ui/avatar.rb +182 -0
- data/lib/plutonium/ui/component/kit.rb +2 -0
- data/lib/plutonium/ui/form/base.rb +16 -2
- data/lib/plutonium/ui/form/components/secure_association.rb +3 -2
- data/lib/plutonium/ui/form/resource.rb +58 -0
- data/lib/plutonium/ui/form/theme.rb +7 -3
- data/lib/plutonium/ui/grid/card.rb +10 -26
- data/lib/plutonium/ui/modal/base.rb +36 -1
- data/lib/plutonium/ui/modal/centered.rb +24 -6
- data/lib/plutonium/ui/modal/slideover.rb +26 -11
- data/lib/plutonium/ui/nav_user.rb +3 -23
- data/lib/plutonium/ui/page/edit.rb +6 -3
- data/lib/plutonium/ui/page/interactive_action.rb +5 -3
- data/lib/plutonium/ui/page/new.rb +6 -3
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +38 -1
- data/src/css/slim_select.css +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +165 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +53 -19
- data/src/js/turbo/index.js +1 -0
- data/src/js/turbo/turbo_confirm.js +128 -0
- metadata +10 -6
- data/lib/plutonium/helpers/attachment_helper.rb +0 -73
- data/lib/plutonium/helpers/table_helper.rb +0 -35
- /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -428,13 +428,21 @@
|
|
|
428
428
|
=================== */
|
|
429
429
|
|
|
430
430
|
.pu-checkbox {
|
|
431
|
-
@apply size-5 rounded-md bg-white border-2 border-slate-300 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 indeterminate:bg-primary-600 indeterminate:border-primary-600 hover:border-primary-400;
|
|
431
|
+
@apply size-5 rounded-md bg-white border-2 border-slate-300 text-primary-600 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 indeterminate:bg-primary-600 indeterminate:border-primary-600 hover:border-primary-400;
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
.dark .pu-checkbox {
|
|
435
435
|
@apply bg-slate-700 border-slate-500 hover:border-primary-400;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
.pu-radio {
|
|
439
|
+
@apply size-5 rounded-full bg-white border-2 border-slate-300 text-primary-600 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 hover:border-primary-400;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.dark .pu-radio {
|
|
443
|
+
@apply bg-slate-700 border-slate-500 hover:border-primary-400;
|
|
444
|
+
}
|
|
445
|
+
|
|
438
446
|
/* ===================
|
|
439
447
|
EMPTY STATE
|
|
440
448
|
=================== */
|
|
@@ -486,6 +494,35 @@
|
|
|
486
494
|
@apply w-14 px-6 py-4;
|
|
487
495
|
}
|
|
488
496
|
|
|
497
|
+
/* ===================
|
|
498
|
+
DIALOG — shared centered <dialog> surface
|
|
499
|
+
===================
|
|
500
|
+
Visual tokens reused by Modal::Centered, the dirty-form-guard
|
|
501
|
+
confirm dialog, and Turbo's themed confirm. Keeps bg/border/radius
|
|
502
|
+
and backdrop styling in one place so a design-token change can't
|
|
503
|
+
silently drift across the three. Animation is driven by toggling
|
|
504
|
+
the [data-open] attribute one frame after showModal() — see
|
|
505
|
+
remote_modal_controller for the rationale (the @starting-style /
|
|
506
|
+
allow-discrete spec dance is unreliable across browsers).
|
|
507
|
+
Positioning, size, and the dialog's own opacity/scale transition
|
|
508
|
+
stay on the call site; they vary per dialog. Slideover has its
|
|
509
|
+
own pinned-right surface and intentionally does not opt in. */
|
|
510
|
+
.pu-dialog {
|
|
511
|
+
background-color: var(--pu-surface);
|
|
512
|
+
border: 1px solid var(--pu-border);
|
|
513
|
+
border-radius: var(--pu-radius-lg);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.pu-dialog::backdrop {
|
|
517
|
+
background-color: transparent;
|
|
518
|
+
transition: background-color 200ms ease-out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.pu-dialog[data-open]::backdrop {
|
|
522
|
+
background-color: rgb(0 0 0 / 0.6);
|
|
523
|
+
backdrop-filter: blur(4px);
|
|
524
|
+
}
|
|
525
|
+
|
|
489
526
|
/* ===================
|
|
490
527
|
ICON RAIL — modern shell
|
|
491
528
|
=================== */
|
data/src/css/slim_select.css
CHANGED
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
@apply !hidden;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/*
|
|
37
|
+
/* Sized to match `.pu-input` so selects line up with text/date/email
|
|
38
|
+
inputs in the same row. */
|
|
38
39
|
.ss-main {
|
|
39
|
-
@apply flex flex-row items-center relative select-none w-full px-
|
|
40
|
+
@apply flex flex-row items-center relative select-none w-full px-3 h-9 cursor-pointer border text-sm outline-none transition-colors duration-200 overflow-hidden focus:ring-2;
|
|
40
41
|
background-color: var(--pu-input-bg);
|
|
41
42
|
border-color: var(--pu-input-border);
|
|
42
43
|
border-radius: var(--pu-radius-md);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="dirty-form-guard"
|
|
4
|
+
// Prompts before dismissing a modal form whose contents have changed.
|
|
5
|
+
// Self-disables when not inside a <dialog>, so it's safe to attach to
|
|
6
|
+
// every form unconditionally.
|
|
7
|
+
//
|
|
8
|
+
// Esc is intercepted at the document's capture phase: relying on the
|
|
9
|
+
// dialog's `cancel` event alone proved flaky under rapid/held Esc when
|
|
10
|
+
// the parent dialog uses `closedby="any"`. The cancel listener stays
|
|
11
|
+
// as defense in depth.
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
static targets = ["confirmDialog"];
|
|
14
|
+
|
|
15
|
+
// Set by controllers, not the user — comparing them would flag
|
|
16
|
+
// every form as dirty on connect (return_to) or on submit (pre_submit).
|
|
17
|
+
static IGNORED_KEYS = new Set(["authenticity_token", "return_to", "pre_submit"]);
|
|
18
|
+
|
|
19
|
+
connect() {
|
|
20
|
+
this.dialog = this.element.closest("dialog");
|
|
21
|
+
if (!this.dialog) return;
|
|
22
|
+
|
|
23
|
+
this.snapshot = this.#serialize();
|
|
24
|
+
this.forceClose = false;
|
|
25
|
+
this.submitting = false;
|
|
26
|
+
|
|
27
|
+
this.onCancel = this.#onCancel.bind(this);
|
|
28
|
+
this.onSubmit = this.#onSubmit.bind(this);
|
|
29
|
+
this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
|
|
30
|
+
this.onConfirmCancel = this.#onConfirmCancel.bind(this);
|
|
31
|
+
this.onKeydown = this.#onKeydown.bind(this);
|
|
32
|
+
|
|
33
|
+
document.addEventListener("keydown", this.onKeydown, true);
|
|
34
|
+
// Capture phase so this runs before remote-modal's cancel handler
|
|
35
|
+
// — that way `defaultPrevented` is visible there if we intervene.
|
|
36
|
+
this.dialog.addEventListener("cancel", this.onCancel, true);
|
|
37
|
+
|
|
38
|
+
this.element.addEventListener("submit", this.onSubmit);
|
|
39
|
+
this.#closeButtons().forEach((btn) =>
|
|
40
|
+
btn.addEventListener("click", this.onCloseButtonClick, true),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (this.hasConfirmDialogTarget) {
|
|
44
|
+
this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
disconnect() {
|
|
49
|
+
if (!this.dialog) return;
|
|
50
|
+
document.removeEventListener("keydown", this.onKeydown, true);
|
|
51
|
+
this.dialog.removeEventListener("cancel", this.onCancel, true);
|
|
52
|
+
this.element.removeEventListener("submit", this.onSubmit);
|
|
53
|
+
this.#closeButtons().forEach((btn) =>
|
|
54
|
+
btn.removeEventListener("click", this.onCloseButtonClick, true),
|
|
55
|
+
);
|
|
56
|
+
if (this.hasConfirmDialogTarget) {
|
|
57
|
+
this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async discard() {
|
|
62
|
+
this.forceClose = true;
|
|
63
|
+
await this.#closeConfirm();
|
|
64
|
+
// Hand off to remote-modal so the parent modal animates out
|
|
65
|
+
// instead of snapping shut.
|
|
66
|
+
this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
keepEditing() {
|
|
70
|
+
this.#closeConfirm();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#closeButtons() {
|
|
74
|
+
if (!this.dialog) return [];
|
|
75
|
+
return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#serialize() {
|
|
79
|
+
const data = new FormData(this.element);
|
|
80
|
+
const enc = encodeURIComponent;
|
|
81
|
+
return [...data.entries()]
|
|
82
|
+
.filter(([key]) => !this.constructor.IGNORED_KEYS.has(key))
|
|
83
|
+
.map(([key, value]) => {
|
|
84
|
+
const v = value instanceof File ? value.name : value;
|
|
85
|
+
return `${enc(key)}=${enc(v)}`;
|
|
86
|
+
})
|
|
87
|
+
.sort()
|
|
88
|
+
.join("&");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#isDirty() {
|
|
92
|
+
return this.#serialize() !== this.snapshot;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#onSubmit() {
|
|
96
|
+
this.submitting = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#confirmIsOpen() {
|
|
100
|
+
return this.hasConfirmDialogTarget && this.confirmDialogTarget.open;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#onKeydown(event) {
|
|
104
|
+
if (event.key !== "Escape") return;
|
|
105
|
+
if (!this.dialog.open) return;
|
|
106
|
+
|
|
107
|
+
// Once the confirm is open, only its buttons may close it.
|
|
108
|
+
if (this.#confirmIsOpen()) {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
event.stopPropagation();
|
|
111
|
+
event.stopImmediatePropagation();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.forceClose || this.submitting) return;
|
|
116
|
+
if (!this.#isDirty()) return;
|
|
117
|
+
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
event.stopImmediatePropagation();
|
|
121
|
+
this.#promptDiscard();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#onCancel(event) {
|
|
125
|
+
if (this.forceClose || this.submitting) return;
|
|
126
|
+
if (!this.#isDirty()) return;
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
this.#promptDiscard();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#onCloseButtonClick(event) {
|
|
132
|
+
if (this.forceClose || this.submitting) return;
|
|
133
|
+
if (!this.#isDirty()) return;
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
this.#promptDiscard();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#onConfirmCancel(event) {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#promptDiscard() {
|
|
144
|
+
if (this.hasConfirmDialogTarget) {
|
|
145
|
+
const d = this.confirmDialogTarget;
|
|
146
|
+
d.showModal();
|
|
147
|
+
requestAnimationFrame(() => {
|
|
148
|
+
requestAnimationFrame(() => d.setAttribute("data-open", ""));
|
|
149
|
+
});
|
|
150
|
+
} else if (window.confirm("Discard your changes?")) {
|
|
151
|
+
this.forceClose = true;
|
|
152
|
+
this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #closeConfirm() {
|
|
157
|
+
if (!this.hasConfirmDialogTarget) return;
|
|
158
|
+
const d = this.confirmDialogTarget;
|
|
159
|
+
if (!d.open) return;
|
|
160
|
+
d.removeAttribute("data-open");
|
|
161
|
+
const animations = d.getAnimations({ subtree: true });
|
|
162
|
+
await Promise.allSettled(animations.map((a) => a.finished));
|
|
163
|
+
d.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -32,6 +32,7 @@ import CaptureUrlController from "./capture_url_controller.js"
|
|
|
32
32
|
import RowClickController from "./row_click_controller.js"
|
|
33
33
|
import ViewSwitcherController from "./view_switcher_controller.js"
|
|
34
34
|
import AutosubmitController from "./autosubmit_controller.js"
|
|
35
|
+
import DirtyFormGuardController from "./dirty_form_guard_controller.js"
|
|
35
36
|
|
|
36
37
|
export default function (application) {
|
|
37
38
|
// Register controllers here
|
|
@@ -68,4 +69,5 @@ export default function (application) {
|
|
|
68
69
|
application.register("row-click", RowClickController)
|
|
69
70
|
application.register("view-switcher", ViewSwitcherController)
|
|
70
71
|
application.register("autosubmit", AutosubmitController)
|
|
72
|
+
application.register("dirty-form-guard", DirtyFormGuardController)
|
|
71
73
|
}
|
|
@@ -1,45 +1,79 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="remote-modal"
|
|
4
|
+
// Drives the open/close lifecycle of a turbo-fetched <dialog>.
|
|
5
|
+
//
|
|
6
|
+
// Entry is animated by deferring `data-open` to the frame after
|
|
7
|
+
// showModal() — the dialog renders one frame with its closed-state
|
|
8
|
+
// transform/opacity, then transitions into the open state. Exit
|
|
9
|
+
// reverses it: remove `data-open`, wait for the dialog's animations
|
|
10
|
+
// to settle, then call close(). This avoids the @starting-style /
|
|
11
|
+
// allow-discrete spec dance, which is unreliable across browsers.
|
|
4
12
|
export default class extends Controller {
|
|
5
13
|
connect() {
|
|
6
|
-
// Store original scroll position and body overflow
|
|
7
14
|
this.originalScrollPosition = window.scrollY;
|
|
8
15
|
this.originalOverflow = document.body.style.overflow;
|
|
9
16
|
this.bodyStateRestored = false;
|
|
10
|
-
|
|
11
|
-
// Lock body scroll
|
|
17
|
+
this._closing = false;
|
|
12
18
|
document.body.style.overflow = "hidden";
|
|
13
19
|
|
|
14
|
-
// Show the modal
|
|
15
20
|
this.element.showModal();
|
|
16
|
-
//
|
|
17
|
-
|
|
21
|
+
// Double rAF ensures the closed-state styles paint before we flip
|
|
22
|
+
// data-open, so the transition actually fires.
|
|
23
|
+
requestAnimationFrame(() => {
|
|
24
|
+
requestAnimationFrame(() => {
|
|
25
|
+
this.element.setAttribute("data-open", "");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
this.onCancel = this.#onCancel.bind(this);
|
|
30
|
+
this.onClose = this.#onClose.bind(this);
|
|
31
|
+
this.onRequestClose = () => this.#animateClose();
|
|
32
|
+
|
|
33
|
+
this.element.addEventListener("cancel", this.onCancel);
|
|
34
|
+
this.element.addEventListener("close", this.onClose);
|
|
35
|
+
this.element.addEventListener("modal:request-close", this.onRequestClose);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
disconnect() {
|
|
39
|
+
this.element.removeEventListener("cancel", this.onCancel);
|
|
40
|
+
this.element.removeEventListener("close", this.onClose);
|
|
41
|
+
this.element.removeEventListener("modal:request-close", this.onRequestClose);
|
|
42
|
+
this.#restoreBodyState();
|
|
18
43
|
}
|
|
19
44
|
|
|
20
45
|
close() {
|
|
21
|
-
|
|
22
|
-
this.element.close();
|
|
23
|
-
this.restoreBodyState();
|
|
46
|
+
this.#animateClose();
|
|
24
47
|
}
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
this
|
|
29
|
-
|
|
49
|
+
#onCancel(event) {
|
|
50
|
+
// Another listener (typically dirty-form-guard) already handled
|
|
51
|
+
// this — don't double-process.
|
|
52
|
+
if (event.defaultPrevented) return;
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
this.#animateClose();
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
this
|
|
57
|
+
#onClose() {
|
|
58
|
+
this.#restoreBodyState();
|
|
34
59
|
}
|
|
35
60
|
|
|
36
|
-
|
|
61
|
+
async #animateClose() {
|
|
62
|
+
if (this._closing) return;
|
|
63
|
+
this._closing = true;
|
|
64
|
+
|
|
65
|
+
this.element.removeAttribute("data-open");
|
|
66
|
+
|
|
67
|
+
const animations = this.element.getAnimations({ subtree: true });
|
|
68
|
+
await Promise.allSettled(animations.map((a) => a.finished));
|
|
69
|
+
|
|
70
|
+
this.element.close();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#restoreBodyState() {
|
|
37
74
|
if (this.bodyStateRestored) return;
|
|
38
75
|
this.bodyStateRestored = true;
|
|
39
|
-
|
|
40
|
-
// Restore body overflow
|
|
41
76
|
document.body.style.overflow = this.originalOverflow || "";
|
|
42
|
-
// Restore the original scroll position
|
|
43
77
|
window.scrollTo(0, this.originalScrollPosition);
|
|
44
78
|
}
|
|
45
79
|
}
|
data/src/js/turbo/index.js
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Themed replacement for Turbo's default window.confirm. The dialog is
|
|
2
|
+
// built lazily and reused so per-call cost is just a textContent swap.
|
|
3
|
+
|
|
4
|
+
let dialog;
|
|
5
|
+
let messageEl;
|
|
6
|
+
let confirmButton;
|
|
7
|
+
let cancelButton;
|
|
8
|
+
|
|
9
|
+
function ensureDialog() {
|
|
10
|
+
// Turbo Drive replaces document.body on full-page navigation, which
|
|
11
|
+
// detaches the cached dialog. showModal() then throws InvalidStateError
|
|
12
|
+
// ("not in a Document"). Re-attach if detached; the node itself plus
|
|
13
|
+
// its listeners survive, so we don't have to rebuild.
|
|
14
|
+
if (dialog) {
|
|
15
|
+
if (!dialog.isConnected) document.body.appendChild(dialog);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
dialog = document.createElement("dialog");
|
|
20
|
+
// Surface (bg, border, radius, backdrop) comes from .pu-dialog; the
|
|
21
|
+
// remaining utilities are positioning, size, and the opacity/scale
|
|
22
|
+
// animation hooks driven by [data-open]. Matches Modal::Centered.
|
|
23
|
+
dialog.className = [
|
|
24
|
+
"pu-dialog",
|
|
25
|
+
"top-1/2",
|
|
26
|
+
"-translate-y-1/2",
|
|
27
|
+
"left-1/2",
|
|
28
|
+
"-translate-x-1/2",
|
|
29
|
+
"w-full",
|
|
30
|
+
"max-w-md",
|
|
31
|
+
"p-0",
|
|
32
|
+
"open:flex",
|
|
33
|
+
"flex-col",
|
|
34
|
+
"opacity-0",
|
|
35
|
+
"scale-95",
|
|
36
|
+
"data-[open]:opacity-100",
|
|
37
|
+
"data-[open]:scale-100",
|
|
38
|
+
"transition-[opacity,transform]",
|
|
39
|
+
"duration-200",
|
|
40
|
+
"ease-out",
|
|
41
|
+
].join(" ");
|
|
42
|
+
dialog.setAttribute("aria-labelledby", "pu-turbo-confirm-message");
|
|
43
|
+
|
|
44
|
+
const header = document.createElement("div");
|
|
45
|
+
header.className = "px-6 pt-5 pb-4 border-b border-[var(--pu-border)]";
|
|
46
|
+
|
|
47
|
+
messageEl = document.createElement("h2");
|
|
48
|
+
messageEl.id = "pu-turbo-confirm-message";
|
|
49
|
+
messageEl.className = "text-lg font-semibold text-[var(--pu-text)]";
|
|
50
|
+
header.appendChild(messageEl);
|
|
51
|
+
|
|
52
|
+
const footer = document.createElement("div");
|
|
53
|
+
footer.className = "flex items-center justify-end gap-2 px-6 py-4";
|
|
54
|
+
|
|
55
|
+
cancelButton = document.createElement("button");
|
|
56
|
+
cancelButton.type = "button";
|
|
57
|
+
cancelButton.className = "pu-btn pu-btn-md pu-btn-outline";
|
|
58
|
+
cancelButton.textContent = "Cancel";
|
|
59
|
+
|
|
60
|
+
confirmButton = document.createElement("button");
|
|
61
|
+
confirmButton.type = "button";
|
|
62
|
+
confirmButton.className = "pu-btn pu-btn-md pu-btn-primary";
|
|
63
|
+
confirmButton.textContent = "Confirm";
|
|
64
|
+
|
|
65
|
+
footer.appendChild(cancelButton);
|
|
66
|
+
footer.appendChild(confirmButton);
|
|
67
|
+
|
|
68
|
+
dialog.appendChild(header);
|
|
69
|
+
dialog.appendChild(footer);
|
|
70
|
+
document.body.appendChild(dialog);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function animateClose() {
|
|
74
|
+
dialog.removeAttribute("data-open");
|
|
75
|
+
const animations = dialog.getAnimations({ subtree: true });
|
|
76
|
+
await Promise.allSettled(animations.map((a) => a.finished));
|
|
77
|
+
if (dialog.open) dialog.close();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function themedConfirm(message) {
|
|
81
|
+
ensureDialog();
|
|
82
|
+
messageEl.textContent = message || "Are you sure?";
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
let settled = false;
|
|
86
|
+
|
|
87
|
+
const settle = (value) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
cleanup();
|
|
91
|
+
resolve(value);
|
|
92
|
+
animateClose();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const onConfirm = () => settle(true);
|
|
96
|
+
const onCancel = () => settle(false);
|
|
97
|
+
const onClose = () => settle(false);
|
|
98
|
+
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
confirmButton.removeEventListener("click", onConfirm);
|
|
101
|
+
cancelButton.removeEventListener("click", onCancel);
|
|
102
|
+
dialog.removeEventListener("close", onClose);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
confirmButton.addEventListener("click", onConfirm);
|
|
106
|
+
cancelButton.addEventListener("click", onCancel);
|
|
107
|
+
// Esc / backdrop / programmatic close — all resolve as cancel.
|
|
108
|
+
dialog.addEventListener("close", onClose);
|
|
109
|
+
|
|
110
|
+
dialog.showModal();
|
|
111
|
+
// Double rAF so the closed-state styles paint before [data-open]
|
|
112
|
+
// flips — same rationale as remote_modal_controller.
|
|
113
|
+
requestAnimationFrame(() => {
|
|
114
|
+
requestAnimationFrame(() => dialog.setAttribute("data-open", ""));
|
|
115
|
+
});
|
|
116
|
+
confirmButton.focus();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof window !== "undefined" && window.Turbo) {
|
|
121
|
+
// Turbo 8 deprecated setConfirmMethod in favor of config.forms.confirm.
|
|
122
|
+
// Prefer the new path; fall back for older Turbo versions still in use.
|
|
123
|
+
if (window.Turbo.config?.forms) {
|
|
124
|
+
window.Turbo.config.forms.confirm = themedConfirm;
|
|
125
|
+
} else if (window.Turbo.setConfirmMethod) {
|
|
126
|
+
window.Turbo.setConfirmMethod(themedConfirm);
|
|
127
|
+
}
|
|
128
|
+
}
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: plutonium
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.53.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stefan Froelich
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-05-
|
|
10
|
+
date: 2026-05-31 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: zeitwerk
|
|
@@ -577,6 +577,7 @@ files:
|
|
|
577
577
|
- docs/public/favicon-16x16.png
|
|
578
578
|
- docs/public/favicon-32x32.png
|
|
579
579
|
- docs/public/favicon.ico
|
|
580
|
+
- docs/public/images/components/avatar.png
|
|
580
581
|
- docs/public/images/guides/custom-actions-bulk.png
|
|
581
582
|
- docs/public/images/guides/multi-tenancy-dashboard.png
|
|
582
583
|
- docs/public/images/guides/multi-tenancy-welcome.png
|
|
@@ -633,6 +634,7 @@ files:
|
|
|
633
634
|
- docs/reference/behavior/index.md
|
|
634
635
|
- docs/reference/behavior/interactions.md
|
|
635
636
|
- docs/reference/behavior/policies.md
|
|
637
|
+
- docs/reference/configuration.md
|
|
636
638
|
- docs/reference/index.md
|
|
637
639
|
- docs/reference/resource/actions.md
|
|
638
640
|
- docs/reference/resource/definition.md
|
|
@@ -668,6 +670,7 @@ files:
|
|
|
668
670
|
- docs/superpowers/specs/2026-05-12-skill-compaction-design.md
|
|
669
671
|
- docs/superpowers/specs/2026-05-13-docs-restructure-design.md
|
|
670
672
|
- docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md
|
|
673
|
+
- docs/superpowers/specs/2026-05-29-avatar-component-design.md
|
|
671
674
|
- esbuild.config.js
|
|
672
675
|
- exe/pug
|
|
673
676
|
- gemfiles/rails_7.gemfile
|
|
@@ -879,13 +882,13 @@ files:
|
|
|
879
882
|
- lib/generators/pu/rodauth/templates/app/rodauth/rodauth_app.rb.tt
|
|
880
883
|
- lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt
|
|
881
884
|
- lib/generators/pu/rodauth/templates/app/views/_login_form_footer.html.erb.tt
|
|
885
|
+
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/change_password_notify.text.erb
|
|
882
886
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/email_auth.text.erb
|
|
883
887
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_disabled.text.erb
|
|
884
888
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_locked_out.text.erb
|
|
885
889
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_setup.text.erb
|
|
886
890
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_unlock_failed.text.erb
|
|
887
891
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_unlocked.text.erb
|
|
888
|
-
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/password_changed.text.erb
|
|
889
892
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/reset_password.text.erb
|
|
890
893
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/reset_password_notify.text.erb
|
|
891
894
|
- lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/unlock_account.text.erb
|
|
@@ -975,10 +978,8 @@ files:
|
|
|
975
978
|
- lib/plutonium/helpers.rb
|
|
976
979
|
- lib/plutonium/helpers/application_helper.rb
|
|
977
980
|
- lib/plutonium/helpers/assets_helper.rb
|
|
978
|
-
- lib/plutonium/helpers/attachment_helper.rb
|
|
979
981
|
- lib/plutonium/helpers/content_helper.rb
|
|
980
982
|
- lib/plutonium/helpers/display_helper.rb
|
|
981
|
-
- lib/plutonium/helpers/table_helper.rb
|
|
982
983
|
- lib/plutonium/helpers/turbo_helper.rb
|
|
983
984
|
- lib/plutonium/helpers/turbo_stream_actions_helper.rb
|
|
984
985
|
- lib/plutonium/interaction/README.md
|
|
@@ -1063,6 +1064,7 @@ files:
|
|
|
1063
1064
|
- lib/plutonium/ui.rb
|
|
1064
1065
|
- lib/plutonium/ui/action_button.rb
|
|
1065
1066
|
- lib/plutonium/ui/actions_dropdown.rb
|
|
1067
|
+
- lib/plutonium/ui/avatar.rb
|
|
1066
1068
|
- lib/plutonium/ui/block.rb
|
|
1067
1069
|
- lib/plutonium/ui/breadcrumbs.rb
|
|
1068
1070
|
- lib/plutonium/ui/color_mode_selector.rb
|
|
@@ -1180,6 +1182,7 @@ files:
|
|
|
1180
1182
|
- src/js/controllers/capture_url_controller.js
|
|
1181
1183
|
- src/js/controllers/clipboard_controller.js
|
|
1182
1184
|
- src/js/controllers/color_mode_controller.js
|
|
1185
|
+
- src/js/controllers/dirty_form_guard_controller.js
|
|
1183
1186
|
- src/js/controllers/easymde_controller.js
|
|
1184
1187
|
- src/js/controllers/filter_panel_controller.js
|
|
1185
1188
|
- src/js/controllers/flatpickr_controller.js
|
|
@@ -1212,6 +1215,7 @@ files:
|
|
|
1212
1215
|
- src/js/support/mime_icon.js
|
|
1213
1216
|
- src/js/turbo/index.js
|
|
1214
1217
|
- src/js/turbo/turbo_actions.js
|
|
1218
|
+
- src/js/turbo/turbo_confirm.js
|
|
1215
1219
|
- src/js/turbo/turbo_debug.js
|
|
1216
1220
|
- src/js/turbo/turbo_frame_monkey_patch.js
|
|
1217
1221
|
- tailwind.config.js
|
|
@@ -1225,7 +1229,7 @@ metadata:
|
|
|
1225
1229
|
homepage_uri: https://radioactive-labs.github.io/plutonium-core/
|
|
1226
1230
|
source_code_uri: https://github.com/radioactive-labs/plutonium-core
|
|
1227
1231
|
post_install_message: |
|
|
1228
|
-
⚠️ Plutonium 0.
|
|
1232
|
+
⚠️ Plutonium 0.53.0 — breaking change
|
|
1229
1233
|
|
|
1230
1234
|
Entity-scoped URL helpers and path params have been renamed from
|
|
1231
1235
|
`<entity>_scope_*` to `<entity>_scoped_*`.
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
module Plutonium
|
|
2
|
-
module Helpers
|
|
3
|
-
module AttachmentHelper
|
|
4
|
-
def attachment_preview(attachments, **options)
|
|
5
|
-
clamp_content begin
|
|
6
|
-
tag.div class: [options[:identity_class], "attachment-preview-container d-flex flex-wrap gap-1 my-1"],
|
|
7
|
-
data: {controller: "attachment-preview-container"} do
|
|
8
|
-
Array(attachments).each do |attachment|
|
|
9
|
-
next unless attachment.url.present?
|
|
10
|
-
|
|
11
|
-
concat begin
|
|
12
|
-
tag.div class: [options[:identity_class], "attachment-preview d-inline-block text-center"],
|
|
13
|
-
title: attachment.filename,
|
|
14
|
-
data: {
|
|
15
|
-
controller: "attachment-preview",
|
|
16
|
-
attachment_preview_mime_type_value: attachment.content_type,
|
|
17
|
-
attachment_preview_thumbnail_url_value: _attachment_thumbnail_url(attachment)
|
|
18
|
-
} do
|
|
19
|
-
tag.figure class: "figure my-1", style: "width: 160px;" do
|
|
20
|
-
concat attachment_preview_thumnail(attachment)
|
|
21
|
-
concat begin
|
|
22
|
-
tag.figcaption class: "figure-caption text-truncate" do
|
|
23
|
-
if options[:caption]
|
|
24
|
-
caption = options[:caption].is_a?(String) ? options[:caption] : attachment.filename
|
|
25
|
-
concat link_to(caption, attachment.url, class: "text-decoration-none", target: :blank)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
if block_given?
|
|
29
|
-
elements = Array(yield attachment).compact
|
|
30
|
-
elements.each { |elem| concat elem }
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def attachment_preview_thumnail(attachment)
|
|
43
|
-
return unless attachment.url.present?
|
|
44
|
-
|
|
45
|
-
# Any changes made here must be reflected in attachment_input_controller#buildPreviewTemplate
|
|
46
|
-
|
|
47
|
-
tag.div class: "bg-white border border-gray-200 rounded-lg shadow hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700", data: {attachment_preview_target: "thumbnail"} do
|
|
48
|
-
thumbnail_url = _attachment_thumbnail_url(attachment)
|
|
49
|
-
link_body = if thumbnail_url
|
|
50
|
-
image_tag thumbnail_url, style: "width:100%; height:100%; object-fit: contain;"
|
|
51
|
-
else
|
|
52
|
-
_attachment_extension(attachment)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
link_to link_body, attachment.url, style: "width:150px; height:150px; line-height: 150px;",
|
|
56
|
-
class: "d-block text-decoration-none user-select-none fs-5 font-monospace text-body-secondary",
|
|
57
|
-
target: :blank,
|
|
58
|
-
data: {attachment_preview_target: "thumbnailLink"}
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
def _attachment_thumbnail_url(attachment)
|
|
65
|
-
attachment.url if attachment.representable?
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def _attachment_extension(attachment)
|
|
69
|
-
attachment.try(:extension) || File.extname(attachment.filename.to_s)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|