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-colors";
20
21pub const ADMIN_TABLE_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition-colors overflow-hidden";
23
24pub const ADMIN_ROW_HOVER: &str =
26 "border-b border-paper-border last:border-b-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors";
27
28pub const CHECKBOX_CLASS: &str = "rounded border-paper-border";
30
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
37pub const BADGE_BASE: &str =
39 "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
40
41pub const BTN_SOLID_GREEN: &str =
45 "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";
46pub const BTN_SOLID_AMBER: &str =
48 "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";
49pub const BTN_SOLID_RED: &str =
51 "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";
52
53pub 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";
57pub 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";
59pub const BTN_TEXT_RED: &str =
61 "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
62pub const BTN_TEXT_ACCENT: &str =
64 "text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
65
66pub const BTN_SECONDARY: &str =
70 "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";
71
72pub const BTN_PRIMARY: &str =
76 "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";
77
78pub const BTN_PRIMARY_SM: &str =
80 "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";
81
82pub const BTN_OUTLINE: &str =
86 "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";
87
88pub const BTN_DANGER_OUTLINE: &str =
90 "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";
91
92pub const BTN_CLOSE_ICON: &str =
96 "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none";
97
98pub const BTN_ICON: &str =
100 "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";
101
102#[component]
130pub fn Pagination(
131 variant: &'static str,
132 current_page: i32,
133 total: i64,
134 per_page: i32,
135 #[props(default = Route::Home {})] prev_route: Route,
136 #[props(default = Route::Home {})] next_route: Route,
137 unit: &'static str,
138 #[props(default)] on_prev: Option<EventHandler<()>>,
139 #[props(default)] on_next: Option<EventHandler<()>>,
140 #[props(default)] on_jump: Option<EventHandler<i32>>,
141) -> Element {
142 let has_prev = current_page > 1;
143 let total_pages = ((total + per_page as i64 - 1) / per_page as i64).max(1) as i32;
144 let has_next = current_page < total_pages;
145
146 let is_admin = variant == "admin";
149 let (link_class, link_extra_next) = if is_admin {
150 (
151 "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",
152 "",
153 )
154 } else {
155 (
156 "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",
157 "ml-auto",
158 )
159 };
160 let disabled_class =
161 "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-secondary border border-paper-border rounded-full cursor-not-allowed";
162
163 let prev_inner = rsx! {
166 span { class: "mr-1", "«" }
167 "上一页"
168 };
169 let next_inner = rsx! {
170 "下一页"
171 span { class: "ml-1", "»" }
172 };
173
174 let mut jump_editing: Signal<bool> = use_signal(|| false);
179 let mut jump_draft: Signal<String> = use_signal(String::new);
180 rsx! {
181 nav { class: if is_admin { "flex mt-6 justify-between" } else { "flex mt-10 mb-6 justify-between" },
182 if has_prev {
183 if let Some(on_prev) = on_prev {
184 button {
185 class: "{link_class}",
186 onclick: move |_| on_prev.call(()),
187 {prev_inner}
188 }
189 } else {
190 Link { class: "{link_class}", to: prev_route, {prev_inner} }
191 }
192 } else if is_admin {
193 span { class: "{disabled_class}",
194 span { class: "mr-1", "«" }
195 "上一页"
196 }
197 }
198
199 if is_admin {
201 span { class: "flex items-center gap-1.5 self-center text-sm text-paper-secondary",
202 if total_pages > 1 && on_jump.is_some() {
203 input {
204 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",
205 r#type: "text",
206 inputmode: "numeric",
207 title: "输入页码,回车跳转",
208 value: if jump_editing() { jump_draft() } else { current_page.to_string() },
209 onfocus: move |_| {
210 jump_draft.set(current_page.to_string());
211 jump_editing.set(true);
212 },
213 onblur: move |_| jump_editing.set(false),
214 oninput: move |e| jump_draft.set(e.value()),
215 onkeydown: move |e| {
216 if e.key() == Key::Enter {
217 if let Some(on_jump) = on_jump {
218 if let Ok(p) = jump_draft().trim().parse::<i32>() {
221 let p = p.clamp(1, total_pages);
222 on_jump.call(p);
223 jump_draft.set(p.to_string());
224 } else {
225 jump_draft.set(current_page.to_string());
226 }
227 }
228 }
229 },
230 }
231 } else {
232 "{current_page}"
233 }
234 " / {total_pages} 页 (共 {total} {unit})"
235 }
236 }
237
238 if has_next {
239 if let Some(on_next) = on_next {
240 button {
241 class: "{link_class} {link_extra_next}",
242 onclick: move |_| on_next.call(()),
243 {next_inner}
244 }
245 } else {
246 Link {
247 class: "{link_class} {link_extra_next}",
248 to: next_route,
249 {next_inner}
250 }
251 }
252 } else if is_admin {
253 span { class: "{disabled_class}",
254 "下一页"
255 span { class: "ml-1", "»" }
256 }
257 }
258 }
259 }
260}
261
262#[component]
272pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
273 rsx! {
274 span { class: "{BADGE_BASE} {color_class}", "{label}" }
275 }
276}
277
278const TOOLTIP_STYLE: &str =
280 "pointer-events-none absolute left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50";
281
282#[component]
296pub fn Tooltip(
297 tip: String,
298 children: Element,
299 #[props(default = "top")] placement: &'static str,
300) -> Element {
301 let position_class = if placement == "bottom" {
303 "top-full mt-2"
304 } else {
305 "bottom-full mb-2"
306 };
307 rsx! {
308 div { class: "group relative inline-flex",
309 {children}
310 div { class: "{TOOLTIP_STYLE} {position_class}", "{tip}" }
311 }
312 }
313}
314
315const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
317const POPOVER_PANEL_CLASS: &str =
319 "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter";
320
321#[component]
351#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
352pub fn Popover(
353 open: bool,
354 anchor_x: i32,
355 anchor_y: i32,
356 children: Element,
357 on_close: EventHandler<()>,
358 #[props(default = "top")] placement: &'static str,
359) -> Element {
360 #[cfg(target_arch = "wasm32")]
364 {
365 use dioxus::prelude::{use_drop, use_effect, use_hook};
366 use std::cell::RefCell;
367 use std::rc::Rc;
368 type EscState =
369 Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
370 let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
371 let state_for_drop = state.clone();
372 let open_for_effect = open;
373 let on_close_for_esc = on_close;
374 use_effect(move || {
375 if !open_for_effect {
376 return;
377 }
378 let Some(window) = web_sys::window() else {
379 return;
380 };
381 let on_close_for_esc = on_close_for_esc;
382 let closure =
385 wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
386 if ev.key() == "Escape" {
387 on_close_for_esc.call(());
388 }
389 })
390 as Box<dyn FnMut(web_sys::KeyboardEvent)>);
391 let _ = window.add_event_listener_with_callback(
392 "keydown",
393 wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
394 );
395 *state.borrow_mut() = Some(closure);
396 });
397 use_drop(move || {
398 if let Some(closure) = state_for_drop.borrow_mut().take() {
399 if let Some(window) = web_sys::window() {
400 let _ = window.remove_event_listener_with_callback(
401 "keydown",
402 wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
403 );
404 }
405 }
406 });
407 }
408
409 if !open {
410 return rsx! {};
411 }
412
413 let style = if placement == "bottom" {
415 format!(
416 "top: {y}px; left: {x}px; transform: translateX(-50%);",
417 x = anchor_x,
418 y = anchor_y + 8
419 )
420 } else {
421 format!(
424 "bottom: calc(100vh - {y}px + 8px); left: {x}px; transform: translateX(-50%);",
425 x = anchor_x,
426 y = anchor_y,
427 )
428 };
429
430 rsx! {
431 div {
433 class: "{POPOVER_OVERLAY_CLASS}",
434 onclick: move |_| on_close.call(()),
435 }
436 div { class: "{POPOVER_PANEL_CLASS}", style: "{style}", {children} }
438 }
439}
440
441static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
442
443#[component]
453pub fn FilterTabs(
454 items: Vec<(&'static str, &'static str)>,
455 active_value: String,
456 on_change: EventHandler<String>,
457) -> Element {
458 #[allow(unused_mut)]
459 let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
460 let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
461
462 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
463 let update_indicator = move |active: String| {
464 spawn(async move {
465 #[cfg(target_arch = "wasm32")]
466 {
467 use wasm_bindgen::JsCast;
468
469 crate::utils::time::sleep_ms(50).await;
471
472 if let Some(window) = web_sys::window() {
473 if let Some(doc) = window.document() {
474 let element_id = format!("tab-{}-{}", id_prefix, active);
475 if let Some(el) = doc.get_element_by_id(&element_id) {
476 if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
477 let left = html_el.offset_left();
478 let width = html_el.offset_width();
479 indicator_style.set(format!(
480 "left: {}px; width: {}px; opacity: 1;",
481 left, width
482 ));
483 }
484 }
485 }
486 }
487 }
488 });
489 };
490
491 use_effect({
492 let active_value = active_value.clone();
493 move || {
494 update_indicator(active_value.clone());
495 }
496 });
497
498 rsx! {
499 div { class: "relative flex gap-4 border-b border-paper-border mb-6",
500 for (value, label) in items {
501 button {
502 id: "tab-{id_prefix}-{value}",
503 key: "{value}",
504 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" },
505 onclick: {
506 let v = value.to_string();
507 move |_| {
508 on_change.call(v.clone());
509 update_indicator(v.clone());
510 }
511 },
512 "{label}"
513 }
514 }
515 div {
517 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",
518 style: "{indicator_style}",
519 }
520 }
521 }
522}
523
524#[component]
541pub fn LoadingButton(
542 label: String,
543 loading: bool,
544 #[props(default = false)] disabled: bool,
545 #[props(default = "primary")] variant: &'static str,
546 onclick: EventHandler<()>,
547) -> Element {
548 let size = if variant == "sm" {
550 "px-4 py-1.5"
551 } else {
552 "px-5 py-2 shadow-sm"
553 };
554
555 let (bg, cursor) = if disabled && !loading {
557 (
558 "bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)]",
559 "cursor-not-allowed",
560 )
561 } else {
562 (
563 "text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] hover:brightness-110 active:scale-[0.98]",
564 "cursor-pointer",
565 )
566 };
567
568 rsx! {
569 button {
570 class: "relative inline-flex items-center justify-center {size} {bg} {cursor} rounded-full text-sm font-medium transition-all",
571 disabled: loading || disabled,
572 onclick: move |_| onclick.call(()),
573 span { class: if loading { "opacity-0" } else { "" }, "{label}" }
574 if loading {
575 span {
576 class: "absolute inset-0 flex items-center justify-center",
577 dangerous_inner_html: SPINNER_SVG,
578 }
579 }
580 }
581 }
582}