1use dioxus::prelude::*;
10use dioxus::router::components::Link;
11
12use crate::router::Route;
13
14pub const ADMIN_CARD_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition";
24
25pub const ADMIN_TABLE_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition overflow-hidden";
27
28#[allow(dead_code)]
29pub const ADMIN_ROW_HOVER: &str =
30 "border-b border-paper-border last:border-b-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors";
31pub const SPINNER_SVG: &str = r#"<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="yggSpinnerGrad"><stop offset="0%" stop-color="currentColor" stop-opacity="1"/><stop offset="100%" stop-color="currentColor" stop-opacity="0.25"/></linearGradient></defs><style>@keyframes yggSpin { to { transform: rotate(360deg); } } .ygg-spinner-circle { transform-origin: 50% 50%; stroke: url(#yggSpinnerGrad); fill: none; animation: yggSpin .5s infinite linear; }</style><circle cx="10" cy="10" r="8" class="ygg-spinner-circle" stroke-width="2"/></svg>"#;
36
37#[allow(dead_code)]
38pub const BADGE_BASE: &str =
39 "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
40pub const MEDIA_BADGE_BASE: &str =
41 "inline-flex items-center px-2 py-0.5 rounded-lg text-[10px] font-mono font-medium whitespace-nowrap";
42
43pub const BTN_SOLID_GREEN: &str =
47 "px-4 py-1.5 text-sm font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors cursor-pointer";
48pub const BTN_SOLID_AMBER: &str =
50 "px-4 py-1.5 text-sm font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-full hover:bg-amber-500/20 transition-colors cursor-pointer";
51pub const BTN_SOLID_RED: &str =
53 "px-4 py-1.5 text-sm font-medium bg-red-500/10 text-red-600 dark:text-red-400 rounded-full hover:bg-red-500/20 transition-colors cursor-pointer";
54
55#[allow(dead_code)]
59pub const BTN_TEXT_GREEN: &str = "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors cursor-pointer";
60#[allow(dead_code)]
61pub const BTN_TEXT_AMBER: &str = "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer";
62#[allow(dead_code)]
63pub const BTN_TEXT_RED: &str =
64 "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
65#[allow(dead_code)]
67pub const BTN_TEXT_ACCENT: &str =
68 "text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
69
70pub const BTN_GHOST: &str =
72 "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer";
73
74pub const BTN_SECONDARY: &str =
78 "px-6 py-2.5 rounded-full text-sm font-medium text-center text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)] active:scale-[0.98] transition-all cursor-pointer";
79
80pub const BTN_PRIMARY: &str =
84 "inline-flex items-center justify-center px-5 py-2 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full shadow-sm hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
85
86pub const BTN_PRIMARY_SM: &str =
88 "inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
89
90pub const BTN_OUTLINE: &str =
94 "relative px-4 py-2 rounded-full text-sm font-medium text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all cursor-pointer";
95
96pub const BTN_DANGER_OUTLINE: &str =
98 "px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-900/50 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer";
99
100pub const BTN_CLOSE_ICON: &str =
104 "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none";
105
106pub const BTN_ICON: &str =
108 "w-9 h-9 flex items-center justify-center text-sm text-paper-secondary hover:text-paper-primary hover:bg-paper-theme cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40";
109
110#[component]
139pub fn Pagination(
140 variant: &'static str,
141 current_page: i32,
142 total: i64,
143 per_page: i32,
144 #[props(default = Route::Home {})] prev_route: Route,
145 #[props(default = Route::Home {})] next_route: Route,
146 unit: &'static str,
147 #[props(default)] on_prev: Option<EventHandler<()>>,
148 #[props(default)] on_next: Option<EventHandler<()>>,
149 #[props(default)] on_jump: Option<EventHandler<i32>>,
150 #[props(default)] compact: bool,
151) -> Element {
152 let has_prev = current_page > 1;
153 let total_pages = ((total + per_page as i64 - 1) / per_page as i64).max(1) as i32;
154 let has_next = current_page < total_pages;
155
156 let is_admin = variant == "admin";
159 let nav_class = if is_admin {
160 if compact {
161 "flex justify-between"
162 } else {
163 "flex mt-6 justify-between"
164 }
165 } else {
166 "flex mt-10 mb-6 justify-between"
167 };
168 let (link_class, link_extra_next) = if is_admin {
169 (
170 "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-primary border border-paper-border rounded-full hover:border-paper-accent hover:text-paper-accent active:scale-[0.98] transition-all duration-200 cursor-pointer",
171 "",
172 )
173 } else {
174 (
175 "inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
176 "ml-auto",
177 )
178 };
179 let disabled_class =
180 "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-secondary border border-paper-border rounded-full cursor-not-allowed";
181
182 let prev_inner = rsx! {
185 span { class: "mr-1", "«" }
186 "上一页"
187 };
188 let next_inner = rsx! {
189 "下一页"
190 span { class: "ml-1", "»" }
191 };
192
193 let mut jump_editing: Signal<bool> = use_signal(|| false);
198 let mut jump_draft: Signal<String> = use_signal(String::new);
199 rsx! {
200 nav { class: nav_class,
201 if has_prev {
202 if let Some(on_prev) = on_prev {
203 button {
204 class: "{link_class}",
205 onclick: move |_| on_prev.call(()),
206 {prev_inner}
207 }
208 } else {
209 Link { class: "{link_class}", to: prev_route, {prev_inner} }
210 }
211 } else if is_admin {
212 span { class: "{disabled_class}",
213 span { class: "mr-1", "«" }
214 "上一页"
215 }
216 }
217
218 if is_admin {
220 span { class: "flex items-center gap-1.5 self-center text-sm text-paper-secondary",
221 if total_pages > 1 && on_jump.is_some() {
222 input {
223 class: "w-11 px-1 py-0.5 text-sm text-center bg-transparent text-paper-primary border border-paper-border rounded-full hover:border-paper-accent/60 focus:outline-none focus:border-paper-accent transition-colors",
224 r#type: "text",
225 inputmode: "numeric",
226 title: "输入页码,回车跳转",
227 value: if jump_editing() { jump_draft() } else { current_page.to_string() },
228 onfocus: move |_| {
229 jump_draft.set(current_page.to_string());
230 jump_editing.set(true);
231 },
232 onblur: move |_| jump_editing.set(false),
233 oninput: move |e| jump_draft.set(e.value()),
234 onkeydown: move |e| {
235 if e.key() == Key::Enter {
236 if let Some(on_jump) = on_jump {
237 if let Ok(p) = jump_draft().trim().parse::<i32>() {
240 let p = p.clamp(1, total_pages);
241 on_jump.call(p);
242 jump_draft.set(p.to_string());
243 } else {
244 jump_draft.set(current_page.to_string());
245 }
246 }
247 }
248 },
249 }
250 } else {
251 "{current_page}"
252 }
253 " / {total_pages} 页 (共 {total} {unit})"
254 }
255 }
256
257 if has_next {
258 if let Some(on_next) = on_next {
259 button {
260 class: "{link_class} {link_extra_next}",
261 onclick: move |_| on_next.call(()),
262 {next_inner}
263 }
264 } else {
265 Link {
266 class: "{link_class} {link_extra_next}",
267 to: next_route,
268 {next_inner}
269 }
270 }
271 } else if is_admin {
272 span { class: "{disabled_class}",
273 "下一页"
274 span { class: "ml-1", "»" }
275 }
276 }
277 }
278 }
279}
280
281#[component]
291pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
292 rsx! {
293 span { class: "{BADGE_BASE} {color_class}", "{label}" }
294 }
295}
296
297#[component]
309pub fn UserAvatar(name: String, avatar_url: Option<String>, class: &'static str) -> Element {
310 let initial = name
311 .chars()
312 .next()
313 .map(|c| c.to_uppercase().collect::<String>())
314 .unwrap_or_else(|| "?".to_string());
315 match avatar_url.filter(|u| !u.trim().is_empty()) {
316 Some(url) => rsx! {
317 img { class: "{class} object-cover", src: "{url}", alt: "{name} 的头像" }
318 },
319 None => rsx! {
320 span { class: "{class} flex items-center justify-center bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-accent)] font-bold select-none",
321 "{initial}"
322 }
323 },
324 }
325}
326
327const TOOLTIP_STYLE: &str =
330 "pointer-events-none absolute px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover/tooltip:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50";
331
332#[component]
350pub fn Tooltip(
351 tip: String,
352 children: Element,
353 #[props(default = "top")] placement: &'static str,
354 #[props(default = "center")] align: &'static str,
355) -> Element {
356 let position_class = if placement == "bottom" {
358 "top-full mt-2"
359 } else {
360 "bottom-full mb-2"
361 };
362 let align_class = match align {
364 "start" => "left-0",
365 "end" => "right-0",
366 _ => "left-1/2 -translate-x-1/2",
367 };
368 rsx! {
369 div { class: "group/tooltip relative inline-flex",
370 {children}
371 div { class: "{TOOLTIP_STYLE} {position_class} {align_class}", "{tip}" }
372 }
373 }
374}
375#[component]
388pub fn CollapsibleSettingsCard(
389 title: String,
390 summary: String,
391 enabled: bool,
392 children: Element,
393 #[props(default)] on_toggle: Option<EventHandler<()>>,
394) -> Element {
395 let mut open = use_signal(|| false);
396 let chevron_rotate = if open() { "rotate-180" } else { "" };
397 let dot_class = if enabled {
398 "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(64,160,43,0.15)]"
399 } else {
400 "w-2 h-2 rounded-full bg-paper-tertiary"
401 };
402
403 rsx! {
404 div { class: "rounded-2xl border border-paper-border overflow-hidden bg-paper-entry",
405 button {
406 r#type: "button",
407 class: "w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-paper-theme focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40",
408 aria_expanded: "{open()}",
409 onclick: move |_| {
410 open.set(!open());
411 if let Some(on_toggle) = on_toggle {
412 on_toggle.call(());
413 }
414 },
415 div { class: "w-2 flex-shrink-0 flex items-center justify-center",
416 div { class: "{dot_class}" }
417 }
418 div { class: "flex-1 min-w-0",
419 div { class: "text-sm font-medium text-paper-primary", "{title}" }
420 div { class: "text-xs text-paper-secondary mt-0.5 truncate", "{summary}" }
421 }
422 svg {
423 class: "w-4 h-4 text-paper-secondary transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
424 view_box: "0 0 24 24",
425 fill: "none",
426 stroke: "currentColor",
427 stroke_width: "2",
428 path {
429 stroke_linecap: "round",
430 stroke_linejoin: "round",
431 d: "M19 9l-7 7-7-7",
432 }
433 }
434 }
435 div {
436 class: "grid transition-all duration-300 ease-in-out",
437 style: if open() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
438 div { class: "overflow-hidden min-h-0", {children} }
439 }
440 }
441 }
442}
443
444const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
446const POPOVER_PANEL_CLASS: &str =
451 "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter";
452const POPOVER_PANEL_EDGE_CLASS: &str =
453 "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter-edge";
454
455#[component]
492#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
493pub fn Popover(
494 open: bool,
495 anchor_x: i32,
496 anchor_y: i32,
497 children: Element,
498 on_close: EventHandler<()>,
499 #[props(default = "top")] placement: &'static str,
500 #[props(default = "center")] align: &'static str,
501) -> Element {
502 #[cfg(target_arch = "wasm32")]
506 {
507 use dioxus::prelude::{use_drop, use_effect, use_hook};
508 use std::cell::RefCell;
509 use std::rc::Rc;
510 type EscState =
511 Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
512 let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
513 let state_for_drop = state.clone();
514 let open_for_effect = open;
515 let on_close_for_esc = on_close;
516 use_effect(move || {
517 if !open_for_effect {
518 return;
519 }
520 let Some(window) = web_sys::window() else {
521 return;
522 };
523 let on_close_for_esc = on_close_for_esc;
524 let closure =
527 wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
528 if ev.key() == "Escape" {
529 on_close_for_esc.call(());
530 }
531 })
532 as Box<dyn FnMut(web_sys::KeyboardEvent)>);
533 let _ = window.add_event_listener_with_callback(
534 "keydown",
535 wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
536 );
537 *state.borrow_mut() = Some(closure);
538 });
539 use_drop(move || {
540 if let Some(closure) = state_for_drop.borrow_mut().take() {
541 if let Some(window) = web_sys::window() {
542 let _ = window.remove_event_listener_with_callback(
543 "keydown",
544 wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
545 );
546 }
547 }
548 });
549 }
550
551 if !open {
552 return rsx! {};
553 }
554
555 let horizontal = match align {
559 "start" => format!("left: {x}px;", x = anchor_x),
560 "end" => format!("right: calc(100vw - {x}px);", x = anchor_x),
561 _ => format!("left: {x}px; transform: translateX(-50%);", x = anchor_x),
562 };
563 let style = if placement == "bottom" {
564 format!("top: {y}px; {horizontal}", y = anchor_y + 8)
565 } else {
566 format!(
569 "bottom: calc(100vh - {y}px + 8px); {horizontal}",
570 y = anchor_y
571 )
572 };
573 let panel_class = if align == "center" {
574 POPOVER_PANEL_CLASS
575 } else {
576 POPOVER_PANEL_EDGE_CLASS
577 };
578
579 rsx! {
580 div {
582 class: "{POPOVER_OVERLAY_CLASS}",
583 onclick: move |_| on_close.call(()),
584 }
585 div { class: "{panel_class}", style: "{style}", {children} }
587 }
588}
589
590static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
591
592#[component]
602pub fn FilterTabs(
603 items: Vec<(&'static str, &'static str)>,
604 active_value: String,
605 on_change: EventHandler<String>,
606) -> Element {
607 #[allow(unused_mut)]
608 let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
609 let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
610
611 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
612 let update_indicator = move |active: String| {
613 spawn(async move {
614 #[cfg(target_arch = "wasm32")]
615 {
616 use wasm_bindgen::JsCast;
617
618 crate::utils::time::sleep_ms(50).await;
620
621 if let Some(window) = web_sys::window() {
622 if let Some(doc) = window.document() {
623 let element_id = format!("tab-{}-{}", id_prefix, active);
624 if let Some(el) = doc.get_element_by_id(&element_id) {
625 if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
626 let left = html_el.offset_left();
627 let width = html_el.offset_width();
628 indicator_style.set(format!(
629 "left: {}px; width: {}px; opacity: 1;",
630 left, width
631 ));
632 }
633 }
634 }
635 }
636 }
637 });
638 };
639
640 use_effect({
641 let active_value = active_value.clone();
642 move || {
643 update_indicator(active_value.clone());
644 }
645 });
646
647 rsx! {
648 div { class: "relative flex gap-4 border-b border-paper-border mb-6",
649 for (value, label) in items {
650 button {
651 id: "tab-{id_prefix}-{value}",
652 key: "{value}",
653 class: if active_value == *value { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors" } else { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors" },
654 onclick: {
655 let v = value.to_string();
656 move |_| {
657 on_change.call(v.clone());
658 update_indicator(v.clone());
659 }
660 },
661 "{label}"
662 }
663 }
664 div {
666 class: "absolute bottom-[-1px] h-[2px] bg-paper-primary transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] pointer-events-none",
667 style: "{indicator_style}",
668 }
669 }
670 }
671}
672
673#[component]
690pub fn LoadingButton(
691 label: String,
692 loading: bool,
693 #[props(default = false)] disabled: bool,
694 #[props(default = "primary")] variant: &'static str,
695 onclick: EventHandler<()>,
696) -> Element {
697 let size = if variant == "sm" {
699 "px-4 py-1.5"
700 } else {
701 "px-5 py-2 shadow-sm"
702 };
703
704 let (bg, cursor) = if disabled && !loading {
706 (
707 "bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)]",
708 "cursor-not-allowed",
709 )
710 } else {
711 (
712 "text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] hover:brightness-110 active:scale-[0.98]",
713 "cursor-pointer",
714 )
715 };
716
717 rsx! {
718 button {
719 class: "relative inline-flex items-center justify-center {size} {bg} {cursor} rounded-full text-sm font-medium transition-all",
720 disabled: loading || disabled,
721 onclick: move |_| onclick.call(()),
722 span { class: if loading { "opacity-0" } else { "" }, "{label}" }
723 if loading {
724 span {
725 class: "absolute inset-0 flex items-center justify-center",
726 dangerous_inner_html: SPINNER_SVG,
727 }
728 }
729 }
730 }
731}
732
733#[component]
745pub fn TagChip(
746 label: String,
747 #[props(default = "outline")] variant: &'static str,
748 #[props(default)] count: Option<i64>,
749 #[props(default)] stop_propagation: bool,
750) -> Element {
751 let class = match variant {
752 "solid" => "inline-flex items-center px-3 py-1.5 text-base font-medium bg-paper-accent-soft text-paper-accent rounded-lg hover:bg-paper-accent hover:text-white transition-all duration-200",
753 _ => "inline-flex items-center px-3 py-1 rounded-full border border-paper-border hover:bg-paper-accent hover:border-paper-accent hover:text-white transition-all duration-200",
754 };
755 rsx! {
756 Link {
757 class: "{class}",
758 to: Route::TagDetail {
759 tag: label.clone(),
760 },
761 onclick: move |evt: dioxus::events::MouseEvent| {
762 if stop_propagation {
763 evt.stop_propagation();
764 }
765 },
766 "{label}"
767 if let Some(c) = count {
768 sup { class: "ml-1 text-sm text-paper-secondary", "{c}" }
769 }
770 }
771 }
772}
773
774#[component]
789pub fn Checkbox(
790 checked: bool,
791 onchange: EventHandler<bool>,
792 #[props(default)] danger: bool,
793) -> Element {
794 let wrap = if danger {
795 "ygg-cb ygg-cb-danger"
796 } else {
797 "ygg-cb"
798 };
799 rsx! {
800 span { class: "{wrap}",
801 input {
802 r#type: "checkbox",
803 checked,
804 onchange: move |e: Event<FormData>| onchange.call(e.checked()),
805 }
806 svg { class: "ygg-cb-mark", view_box: "0 0 16 16",
807 path { class: "ygg-cb-check", d: "M3.5 8.5l3 3 6-6.5" }
808 }
809 }
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 #[test]
818 fn tooltip_uses_named_group_to_prevent_ancestor_trigger() {
819 assert!(
820 TOOLTIP_STYLE.contains("group-hover/tooltip:opacity-100"),
821 "Tooltip 必须使用专属命名空间 group-hover/tooltip:opacity-100,避免被外层祖先 group 误触"
822 );
823 }
824}