diff --git a/assets/tailwind.css b/assets/tailwind.css index c91ef18..8fcef68 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -787,15 +787,6 @@ html { color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); } - .menu li > *:not(ul, .menu-title, details, .btn):active, -.menu li > *:not(ul, .menu-title, details, .btn).active, -.menu li > details > summary:active { - --tw-bg-opacity: 1; - background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); - --tw-text-opacity: 1; - color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); - } - .tab:hover { --tw-text-opacity: 1; } @@ -952,6 +943,22 @@ html { color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); } +.checkbox { + flex-shrink: 0; + --chkbg: var(--fallback-bc,oklch(var(--bc)/1)); + --chkfg: var(--fallback-b1,oklch(var(--b1)/1)); + height: 1.5rem; + width: 1.5rem; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-radius: var(--rounded-btn, 0.5rem); + border-width: 1px; + border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity))); + --tw-border-opacity: 0.2; +} + .collapse:not(td):not(tr):not(colgroup) { visibility: visible; } @@ -1066,46 +1073,6 @@ html { transition-duration: 200ms; } -.dropdown-end .dropdown-content { - inset-inline-end: 0px; -} - -.dropdown-left .dropdown-content { - bottom: auto; - inset-inline-end: 100%; - top: 0px; - transform-origin: right; -} - -.dropdown-right .dropdown-content { - bottom: auto; - inset-inline-start: 100%; - top: 0px; - transform-origin: left; -} - -.dropdown-bottom .dropdown-content { - bottom: auto; - top: 100%; - transform-origin: top; -} - -.dropdown-top .dropdown-content { - bottom: 100%; - top: auto; - transform-origin: bottom; -} - -.dropdown-end.dropdown-right .dropdown-content { - bottom: 0px; - top: auto; -} - -.dropdown-end.dropdown-left .dropdown-content { - bottom: 0px; - top: auto; -} - .dropdown.dropdown-open .dropdown-content, .dropdown:not(.dropdown-hover):focus .dropdown-content, .dropdown:focus-within .dropdown-content { @@ -1289,18 +1256,6 @@ html { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } - :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { - cursor: pointer; - outline: 2px solid transparent; - outline-offset: 2px; - } - - @supports (color: oklch(0% 0 0)) { - :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { - background-color: var(--fallback-bc,oklch(var(--bc)/0.1)); - } - } - .tab[disabled], .tab[disabled]:hover { cursor: not-allowed; @@ -1473,34 +1428,6 @@ html { } } -.menu { - display: flex; - flex-direction: column; - flex-wrap: wrap; - font-size: 0.875rem; - line-height: 1.25rem; - padding: 0.5rem; -} - -.menu :where(li ul) { - position: relative; - white-space: nowrap; - margin-inline-start: 1rem; - padding-inline-start: 0.5rem; -} - -.menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), .menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { - display: grid; - grid-auto-flow: column; - align-content: flex-start; - align-items: center; - gap: 0.5rem; - grid-auto-columns: minmax(auto, max-content) auto max-content; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; -} - .menu li.disabled { cursor: not-allowed; -webkit-user-select: none; @@ -1509,19 +1436,6 @@ html { color: var(--fallback-bc,oklch(var(--bc)/0.3)); } -.menu :where(li > .menu-dropdown:not(.menu-dropdown-show)) { - display: none; -} - -:where(.menu li) { - position: relative; - display: flex; - flex-shrink: 0; - flex-direction: column; - flex-wrap: wrap; - align-items: stretch; -} - :where(.menu li) .badge { justify-self: end; } @@ -2098,6 +2012,54 @@ input.tab:checked + .tab-content, border-radius: inherit; } +.checkbox:focus { + box-shadow: none; +} + +.checkbox:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/1)); +} + +.checkbox:disabled { + border-width: 0px; + cursor: not-allowed; + border-color: transparent; + --tw-bg-opacity: 1; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + opacity: 0.2; +} + +.checkbox:checked, + .checkbox[aria-checked="true"] { + background-repeat: no-repeat; + animation: checkmark var(--animation-input, 0.2s) ease-out; + background-color: var(--chkbg); + background-image: linear-gradient(-45deg, transparent 65%, var(--chkbg) 65.99%), + linear-gradient(45deg, transparent 75%, var(--chkbg) 75.99%), + linear-gradient(-45deg, var(--chkbg) 40%, transparent 40.99%), + linear-gradient( + 45deg, + var(--chkbg) 30%, + var(--chkfg) 30.99%, + var(--chkfg) 40%, + transparent 40.99% + ), + linear-gradient(-45deg, var(--chkfg) 50%, var(--chkbg) 50.99%); +} + +.checkbox:indeterminate { + --tw-bg-opacity: 1; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + background-repeat: no-repeat; + animation: checkmark var(--animation-input, 0.2s) ease-out; + background-image: linear-gradient(90deg, transparent 80%, var(--chkbg) 80%), + linear-gradient(-90deg, transparent 80%, var(--chkbg) 80%), + linear-gradient(0deg, var(--chkbg) 43%, var(--chkfg) 43%, var(--chkfg) 57%, var(--chkbg) 57%); +} + @keyframes checkmark { 0% { background-position-y: 5px; @@ -2392,88 +2354,6 @@ details.collapse summary::-webkit-details-marker { mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } -:where(.menu li:empty) { - --tw-bg-opacity: 1; - background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); - opacity: 0.1; - margin: 0.5rem 1rem; - height: 1px; -} - -.menu :where(li ul):before { - position: absolute; - bottom: 0.75rem; - inset-inline-start: 0px; - top: 0.75rem; - width: 1px; - --tw-bg-opacity: 1; - background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); - opacity: 0.1; - content: ""; -} - -.menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), -.menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { - border-radius: var(--rounded-btn, 0.5rem); - padding-left: 1rem; - padding-right: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - text-align: start; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-timing-function: cubic-bezier(0, 0, 0.2, 1); - transition-duration: 200ms; - text-wrap: balance; -} - -:where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn).focus, :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn):focus, :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):is(summary):not(.active, .btn):focus-visible, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn).focus, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn):focus, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):is(summary):not(.active, .btn):focus-visible { - cursor: pointer; - background-color: var(--fallback-bc,oklch(var(--bc)/0.1)); - --tw-text-opacity: 1; - color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); - outline: 2px solid transparent; - outline-offset: 2px; -} - -.menu li > *:not(ul, .menu-title, details, .btn):active, -.menu li > *:not(ul, .menu-title, details, .btn).active, -.menu li > details > summary:active { - --tw-bg-opacity: 1; - background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); - --tw-text-opacity: 1; - color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); -} - -.menu :where(li > details > summary)::-webkit-details-marker { - display: none; -} - -.menu :where(li > details > summary):after, -.menu :where(li > .menu-dropdown-toggle):after { - justify-self: end; - display: block; - margin-top: -0.5rem; - height: 0.5rem; - width: 0.5rem; - transform: rotate(45deg); - transition-property: transform, margin-top; - transition-duration: 0.3s; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - content: ""; - transform-origin: 75% 75%; - box-shadow: 2px 2px; - pointer-events: none; -} - -.menu :where(li > details[open] > summary):after, -.menu :where(li > .menu-dropdown-toggle.menu-dropdown-show):after { - transform: rotate(225deg); - margin-top: 0; -} - .mockup-browser .mockup-browser-toolbar .input { position: relative; margin-left: auto; @@ -3228,10 +3108,6 @@ details.collapse summary::-webkit-details-marker { z-index: 50; } -.z-\[1\] { - z-index: 1; -} - .mx-1 { margin-left: 0.25rem; margin-right: 0.25rem; @@ -3384,6 +3260,10 @@ details.collapse summary::-webkit-details-marker { flex: none; } +.cursor-pointer { + cursor: pointer; +} + .select-none { -webkit-user-select: none; -moz-user-select: none; @@ -3462,10 +3342,6 @@ details.collapse summary::-webkit-details-marker { white-space: nowrap; } -.rounded-box { - border-radius: var(--rounded-box, 1rem); -} - .rounded-b-none { border-bottom-right-radius: 0px; border-bottom-left-radius: 0px; @@ -3476,11 +3352,6 @@ details.collapse summary::-webkit-details-marker { border-top-right-radius: 0px; } -.bg-base-100 { - --tw-bg-opacity: 1; - background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); -} - .bg-base-200 { --tw-bg-opacity: 1; background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); @@ -3495,10 +3366,6 @@ details.collapse summary::-webkit-details-marker { background-color: rgb(0 0 0 / 0.5); } -.p-2 { - padding: 0.5rem; -} - .p-5 { padding: 1.25rem; } @@ -3581,12 +3448,6 @@ details.collapse summary::-webkit-details-marker { color: var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity))); } -.shadow { - --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); -} - .shadow-xl { --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); diff --git a/src/components/admin/members.rs b/src/components/admin/members.rs index c0ccb78..14c3b71 100644 --- a/src/components/admin/members.rs +++ b/src/components/admin/members.rs @@ -77,7 +77,23 @@ async fn fetch_members() -> Result, ServerFnError> { #[component] fn MemberModal(member: Signal>) -> Element { - let mut groups_focus = use_signal(|| false); + let mut member_groups = use_signal(|| vec![]); + let mut loading = use_signal(|| false); + + use_effect(move || { + if let Some(m) = member() { + member_groups.set(m.groups); + } + }); + + let submit = move |_| async move { + loading.set(true); + if let Some(m) = member() { + if let Ok(_res) = update_groups(m.id, member_groups()).await {}; + }; + loading.set(false); + member.set(None) + }; rsx! { dialog { @@ -87,80 +103,7 @@ fn MemberModal(member: Signal>) -> Element { class: "modal-box", h3 { class: "text-lg font-bold", "Lid bewerken" }, if let Some(m) = member() { - div { - div { - class: "grid gap-3 sm:grid-flow-col sm:grid-rows-3 sm:grid-cols-2", - label { - class: "form-control w-full", - div { - class: "label", - span { class: "label-text", "Naam" } - } - b { class: "select-all", "{m.name.full}" }, - } - label { - class: "form-control w-full", - div { - class: "label", - span { class: "label-text", "Relatiecode" } - } - b { class: "select-all", "{m.id}" }, - } - label { - class: "form-control w-full", - div { - class: "label", - span { class: "label-text", "Registratiecode" } - } - b { class: "select-all", {m.registration_token.unwrap_or("geen".to_string())} }, - } - label { - class: "form-control w-full", - div { - class: "label", - span { class: "label-text", "Uur" } - } - b { class: "select-all", {m.hours.join(", ")} }, - } - label { - class: "form-control w-full", - div { - class: "label", - span { class: "label-text", "Diploma" } - } - b { class: "select-all", {m.diploma.unwrap_or("geen".to_string())} }, - } - } - div { - class: "mt-5", - div { - class: "dropdown w-full", - label { - class: "form-control w-full", - div { - class: "label", - span { class: "label-text", "Groepen" } - } - input { - r#type: "text", - class: "input input-bordered", - onfocusin: move |_| { - groups_focus.set(true); - }, - onfocusout: move |_| { - groups_focus.set(false); - }, - } - } - ul { - class: "dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow w-full", - li { "Kader" } - li { "Wedstrijden" } - li { "Bestuur" } - } - } - } - } + MemberView { member: m, member_groups: member_groups } } else { "Geen lid geselecteerd" } @@ -169,8 +112,21 @@ fn MemberModal(member: Signal>) -> Element { button { class: "btn", onclick: move |_| member.set(None), + disabled: loading(), + if loading() { + span { class: "loading loading-spinner" } + }, "Annuleren" } + button { + class: "btn btn-primary", + onclick: submit, + disabled: loading(), + if loading() { + span { class: "loading loading-spinner" } + }, + "Bewerken" + } } } label { @@ -180,3 +136,103 @@ fn MemberModal(member: Signal>) -> Element { } } } + +#[component] +fn MemberView(member: Member, member_groups: Signal>) -> Element { + let groups = use_context::(); + + rsx! { + div { + div { + class: "grid gap-3 sm:grid-flow-col sm:grid-rows-3 sm:grid-cols-2", + label { + class: "form-control w-full", + div { + class: "label", + span { class: "label-text", "Naam" } + } + b { class: "select-all", "{member.name.full}" }, + } + label { + class: "form-control w-full", + div { + class: "label", + span { class: "label-text", "Relatiecode" } + } + b { class: "select-all", "{member.id}" }, + } + label { + class: "form-control w-full", + div { + class: "label", + span { class: "label-text", "Registratiecode" } + } + b { class: "select-all", {member.registration_token.unwrap_or("geen".to_string())} }, + } + label { + class: "form-control w-full", + div { + class: "label", + span { class: "label-text", "Uur" } + } + b { class: "select-all", {member.hours.join(", ")} }, + } + label { + class: "form-control w-full", + div { + class: "label", + span { class: "label-text", "Diploma" } + } + b { class: "select-all", {member.diploma.unwrap_or("geen".to_string())} }, + } + } + div { + class: "mt-5", + label { + class: "form-control w-full", + div { + class: "label", + span { class: "label-text", "Groepen" } + } + div { + class: "grid sm:grid-cols-2", + for (group_id, group_name) in groups.0 { + div { + class: "form-control", + label { + class: "label cursor-pointer justify-start gap-2", + input { + r#type: "checkbox", + class: "checkbox", + checked: member_groups.read().contains(&group_id), + oninput: move |event| { + if event.value() == "true".to_string() { + member_groups.push(group_id.clone()); + } else { + member_groups.retain(|x| *x != group_id); + } + } + } + span { class: "label-text", "{group_name}" }, + } + } + } + } + } + } + } + } +} + +#[server] +async fn update_groups(member_id: String, groups: Vec) -> Result<(), ServerFnError> { + let user = Session::fetch_current_user().await?; + + if !user.admin { + return Err(crate::Error::NoPermissions.into()); + } + + Member::set_groups(&member_id, groups).await?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index e977303..86dd96e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,8 @@ mod components; mod err; mod util; +use std::collections::HashMap; + #[cfg(feature = "server")] pub use err::Error; @@ -85,7 +87,20 @@ fn main() { launch(App); } +#[derive(Clone)] +pub struct Groups<'a>(Vec<(String, &'a str)>); + fn App() -> Element { + let groups = Groups(vec![ + ("bestuur".to_string(), "Bestuur"), + ("kader".to_string(), "Kader"), + ("varendredden".to_string(), "Varend redden"), + ("wedstrijden".to_string(), "Wedstrijden"), + ("wedstrijden_trainer".to_string(), "Wedstrijden trainer"), + ]); + + use_context_provider(|| groups); + rsx! { div { class: "h-screen flex flex-col", diff --git a/src/util/model/member.rs b/src/util/model/member.rs index de434ec..85e8679 100644 --- a/src/util/model/member.rs +++ b/src/util/model/member.rs @@ -71,6 +71,21 @@ impl Member { Ok(members) } + + pub async fn set_groups(member_id: &str, groups: Vec) -> Result { + let mut res = DB + .query("UPDATE $member SET groups = $groups") + .bind(("groups", groups)) + .bind(("member", Thing::from(("member", member_id)))) + .await?; + + res = res.check()?; + + match res.take(0)? { + Some(m) => Ok(m), + None => Err(crate::Error::NoDocument), + } + } } impl MembersMigration {