1use dioxus::prelude::*;
7use dioxus::router::components::Link;
8
9use crate::api::auth::{get_current_user, logout};
10use crate::components::skeletons::admin_comments_skeleton::AdminCommentsSkeleton;
11use crate::components::skeletons::assets_skeleton::AssetsSkeleton;
12use crate::components::skeletons::dashboard_skeleton::AdminDashboardSkeleton;
13
14use crate::components::skeletons::friends_admin_skeleton::FriendsAdminSkeleton;
15use crate::components::skeletons::logs_skeleton::LogsSkeleton;
16use crate::components::skeletons::mcp_skeleton::McpSkeleton;
17use crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton;
18use crate::components::skeletons::posts_skeleton::PostsSkeleton;
19use crate::components::skeletons::posts_trash_skeleton::PostsTrashSkeleton;
20use crate::components::skeletons::profile_skeleton::ProfileSkeleton;
21use crate::components::skeletons::runner_skeleton::RunnerSkeleton;
22use crate::components::skeletons::settings_admin_skeleton::SettingsAdminSkeleton;
23use crate::components::skeletons::system_skeleton::SystemSkeleton;
24use crate::components::skeletons::write_skeleton::WriteSkeleton;
25use crate::components::ui::UserAvatar;
26use crate::context::UserContext;
27use crate::router::Route;
28use crate::theme::ThemeToggle;
29
30#[component]
31pub fn AdminLayout() -> Element {
32 let mut ctx: UserContext = use_context();
33 let navigator = dioxus::router::navigator();
34 let route = use_route::<Route>();
35
36 use_effect(move || {
37 if !(ctx.checked)() {
38 (ctx.checked).set(true);
39 spawn(async move {
40 match get_current_user().await {
41 Ok(response) => {
42 if let Some(user) = response.user {
43 ctx.user.set(Some(std::sync::Arc::new(user)));
44 } else {
45 let _ = navigator.push(Route::Login {});
46 }
47 }
48 Err(_) => {
49 let _ = navigator.push(Route::Login {});
50 }
51 }
52 });
53 }
54 });
55
56 let is_write_route =
57 matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
58 let internal_scroll_route = is_write_route
60 || matches!(route, Route::SiteSettingsPage {})
61 || matches!(route, Route::Logs {});
62 let side_user = (ctx.user)();
63 let sidebar = rsx! {
64 AdminSidebar {
65 route: route.clone(),
66 user_name: side_user.as_ref().map(|user| user.display_label().to_string()),
67 avatar_url: side_user.as_ref().and_then(|user| user.avatar_url.clone()),
68 on_logout: move |_| {
69 spawn(async move {
70 if logout().await.is_ok_and(|response| response.success) {
71 crate::bridges::navigation::clear_admin_state();
72 }
73 ctx.user.set(None);
74 ctx.checked.set(false);
75 let _ = navigator.push(Route::Login {});
76 });
77 },
78 }
79 };
80 let authenticated = matches!(((ctx.checked)(), (ctx.user)()), (true, Some(_)));
81 let skeleton_route = route.clone();
82 let content = if authenticated {
83 rsx! {
84 SuspenseBoundary {
86 fallback: move |_| rsx! {
87 div { class: "flex-1 min-h-0 flex flex-col animate-pulse", {admin_route_skeleton(&skeleton_route)} }
88 },
89 Outlet::<Route> {}
90 }
91 }
92 } else {
93 rsx! {
94 div { class: "flex-1 min-h-0 flex flex-col animate-pulse",
95 {admin_route_skeleton(&route)}
96 }
97 }
98 };
99 rsx! {
100 AdminShell {
101 sidebar,
102 content,
103 internal_scroll: internal_scroll_route,
104 checking: !authenticated,
105 }
106 }
107}
108
109#[component]
111pub(crate) fn AdminShell(
112 sidebar: Element,
113 content: Element,
114 #[props(default)] internal_scroll: bool,
115 #[props(default)] checking: bool,
116 #[props(default)] preview: bool,
117) -> Element {
118 let root_class = if preview {
119 "showcase-admin-shell flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans w-full h-full overflow-hidden"
120 } else {
121 "min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans"
122 };
123 let height_class = if preview { "h-full" } else { "h-screen" };
124 let card_overflow = if internal_scroll || checking {
125 "overflow-hidden"
126 } else {
127 "overflow-y-auto"
128 };
129 let main_class = if preview || internal_scroll {
130 "flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
131 } else {
132 "flex-1 w-full max-w-7xl mx-auto px-6 py-12"
133 };
134 rsx! {
135 div { class: "{root_class}",
136 {sidebar}
137 div { class: "flex-1 flex flex-col min-w-0 {height_class} p-2 md:p-4",
138 div {
139 "data-vt-scroll": (!preview).then_some("admin-main"),
140 class: "flex-1 min-h-0 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] {card_overflow} relative flex flex-col",
141 main { class: "{main_class}", {content} }
142 }
143 }
144 }
145 }
146}
147
148#[component]
150pub(crate) fn AdminSidebar(
151 route: Route,
152 user_name: Option<String>,
153 avatar_url: Option<String>,
154 on_logout: EventHandler<()>,
155 #[props(default)] demo_active: Option<String>,
156 #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
157 #[props(default)] preview: bool,
158) -> Element {
159 let nav_items_top = vec![(Route::Admin {}, "仪表盘"), (Route::Write {}, "写文章")];
160 let nav_items_bottom = vec![(Route::Assets {}, "素材"), (Route::FriendsAdmin {}, "友链")];
161 let is_write_route =
162 matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
163 let is_profile_route = demo_active
164 .as_deref()
165 .map_or(matches!(route, Route::Profile {}), |active| {
166 active == "个人信息"
167 });
168 let chip_state_class = if is_profile_route {
169 "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
170 } else {
171 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
172 };
173 let aside_class = if preview {
174 "w-48 flex-shrink-0 flex flex-col h-full sticky top-0 p-3 bg-[var(--color-paper-entry)] overflow-hidden"
175 } else {
176 "w-48 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-3 bg-[var(--color-paper-entry)]"
177 };
178 rsx! {
179 aside { "data-vt-shell": (!preview).then_some("admin-sidebar"), class: "{aside_class}",
180 div { class: "mb-8 px-3 flex-shrink-0",
181 if let Some(on_navigate) = on_demo_navigate {
182 button {
183 class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
184 r#type: "button",
185 onclick: move |_| on_navigate.call("仪表盘"),
186 "Yggdrasil."
187 }
188 } else {
189 Link {
191 class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
192 to: NavigationTarget::<Route>::External("/".to_string()),
193 "Yggdrasil."
194 }
195 }
196 }
197 nav { class: "flex-1 min-h-0 flex flex-col gap-2 overflow-y-auto overflow-x-hidden pr-0.5",
198 for (dest, label) in nav_items_top {
199 {nav_item(&route, dest, label, is_write_route, demo_active.as_deref(), on_demo_navigate)}
200 }
201 ContentNavGroup { demo_active: demo_active.clone(), on_demo_navigate }
202 for (dest, label) in nav_items_bottom {
203 {nav_item(&route, dest, label, is_write_route, demo_active.as_deref(), on_demo_navigate)}
204 }
205 ToolsNavGroup { demo_active: demo_active.clone(), on_demo_navigate }
206 }
207 div { class: "mt-auto pt-4 border-t border-[var(--color-paper-border)] flex flex-col gap-1 flex-shrink-0",
208 if let Some(name) = user_name {
209 if let Some(on_navigate) = on_demo_navigate {
210 button {
211 class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
212 r#type: "button",
213 onclick: move |_| on_navigate.call("个人信息"),
214 UserAvatar {
215 name: name.clone(),
216 avatar_url: avatar_url.clone(),
217 class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
218 }
219 span { class: "truncate", "{name}" }
220 }
221 } else {
222 Link {
223 class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
224 to: Route::Profile {},
225 UserAvatar {
226 name: name.clone(),
227 avatar_url: avatar_url.clone(),
228 class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
229 }
230 span { class: "truncate", "{name}" }
231 }
232 }
233 } else {
234 div { class: "flex items-center gap-2.5 px-3 py-2",
235 div { class: "w-7 h-7 rounded-full bg-[var(--color-paper-theme)] animate-pulse flex-shrink-0" }
236 div { class: "h-3.5 w-16 rounded bg-[var(--color-paper-theme)] animate-pulse" }
237 }
238 }
239 div { class: "flex items-center justify-between px-3",
240 ThemeToggle {}
241 button {
242 class: "text-sm font-medium px-3 py-1.5 rounded-2xl bg-[var(--color-paper-theme)] border border-[var(--color-paper-border)] shadow-sm hover:shadow-md transition-all text-[var(--color-paper-secondary)] hover:text-red-500 cursor-pointer",
243 r#type: "button",
244 onclick: move |_| on_logout.call(()),
245 "退出"
246 }
247 }
248 }
249 }
250 }
251}
252
253fn nav_item(
254 route: &Route,
255 dest: Route,
256 label: &'static str,
257 is_write_route: bool,
258 demo_active: Option<&str>,
259 on_demo_navigate: Option<EventHandler<&'static str>>,
260) -> Element {
261 let is_active = demo_active.map_or(
262 *route == dest || (label == "写文章" && is_write_route),
263 |active| active == label,
264 );
265 let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
266 let text_class = if is_active {
267 "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
268 } else {
269 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
270 };
271 rsx! {
272 if let Some(on_navigate) = on_demo_navigate {
273 button { key: "{label}", class: "{base_class} {text_class}", r#type: "button", onclick: move |_| on_navigate.call(label), "{label}" }
274 } else {
275 Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
276 }
277 }
278}
279
280fn admin_route_skeleton(route: &Route) -> Element {
282 match route {
283 Route::Admin {} => rsx! {
284 AdminDashboardSkeleton {}
285 },
286 Route::Write {} | Route::WriteEdit { .. } => rsx! {
287 WriteSkeleton {}
288 },
289 Route::Posts {} => rsx! {
290 PostsSkeleton {}
291 },
292 Route::AdminNotes {}
293 | Route::AdminNotebooks {}
294 | Route::NewNote {}
295 | Route::EditNote { .. } => rsx! {
296 crate::pages::notes::NotesSkeleton {}
297 },
298 Route::PostsTrash {} => rsx! {
299 PostsTrashSkeleton {}
300 },
301 Route::Assets {} => rsx! {
302 AssetsSkeleton {}
303 },
304 Route::FriendsAdmin {} => rsx! {
305 FriendsAdminSkeleton {}
306 },
307 Route::AdminComments {} | Route::AdminCommentsPage { .. } => {
308 rsx! {
309 AdminCommentsSkeleton {}
310 }
311 }
312 Route::System {} => rsx! {
313 SystemSkeleton {}
314 },
315 Route::Runner {} => rsx! {
316 RunnerSkeleton {}
317 },
318 Route::Mcp {} => rsx! {
319 McpSkeleton {}
320 },
321 Route::Logs {} => rsx! {
322 LogsSkeleton {}
323 },
324 Route::SiteSettingsPage {} => rsx! {
325 SettingsAdminSkeleton {}
326 },
327 Route::Profile {} => rsx! {
328 ProfileSkeleton {}
329 },
330 Route::PostPreview { .. } => rsx! {
331 PostPreviewSkeleton {}
332 },
333 _ => rsx! {
334 AdminDashboardSkeleton {}
335 },
336 }
337}
338
339#[derive(Clone, Copy, PartialEq)]
346enum NavGroupKind {
347 Content,
349 Tools,
351}
352
353impl NavGroupKind {
354 fn label(self) -> &'static str {
355 match self {
356 NavGroupKind::Content => "内容管理",
357 NavGroupKind::Tools => "工具",
358 }
359 }
360
361 fn contains(self, route: &Route) -> bool {
363 match self {
364 NavGroupKind::Content => matches!(
365 route,
366 Route::Posts {}
367 | Route::AdminNotes {}
368 | Route::AdminNotebooks {}
369 | Route::NewNote {}
370 | Route::EditNote { .. }
371 | Route::PostsTrash {}
372 | Route::AdminComments {}
373 | Route::AdminCommentsPage { .. }
374 ),
375 NavGroupKind::Tools => matches!(
376 route,
377 Route::Runner {}
378 | Route::Mcp {}
379 | Route::System {}
380 | Route::SiteSettingsPage {}
381 | Route::Logs {}
382 ),
383 }
384 }
385}
386
387#[component]
394fn NavGroup(
395 kind: NavGroupKind,
396 items: Vec<(Route, &'static str, bool)>,
397 #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
398) -> Element {
399 let route = use_route::<Route>();
400 let group_active = if on_demo_navigate.is_some() {
401 items.iter().any(|(_, _, active)| *active)
402 } else {
403 kind.contains(&route)
404 };
405 let mut expanded = use_signal(|| group_active);
406 let mut was_in_group = use_signal(|| group_active);
410
411 use_effect(move || {
414 if on_demo_navigate.is_some() {
415 return;
416 }
417 let current = router().current::<Route>();
418 let now_in_group = kind.contains(¤t);
419 let was = was_in_group();
420
421 if now_in_group && !was {
422 expanded.set(true);
423 }
424 if was != now_in_group {
425 was_in_group.set(now_in_group);
426 }
427 });
428
429 let chevron_rotate = if expanded() { "rotate-180" } else { "" };
430 let parent_text_class = if group_active {
433 "text-[var(--color-paper-primary)] border border-transparent"
434 } else {
435 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
436 };
437 let label = kind.label();
438
439 rsx! {
440 div { class: "flex flex-col gap-1",
441 button {
442 class: "flex items-center justify-between w-full px-3 py-2.5 rounded-2xl text-sm font-medium transition-all cursor-pointer {parent_text_class}",
443 r#type: "button",
444 aria_expanded: expanded(),
445 onclick: move |_| expanded.set(!expanded()),
446 span { "{label}" }
447 svg {
448 class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
449 view_box: "0 0 24 24",
450 fill: "none",
451 stroke: "currentColor",
452 stroke_width: "2",
453 path {
454 stroke_linecap: "round",
455 stroke_linejoin: "round",
456 d: "M19 9l-7 7-7-7",
457 }
458 }
459 }
460 div {
462 class: "grid transition-all duration-300 ease-in-out",
463 style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
464 div { class: "overflow-hidden min-h-0",
465 div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
467 for (dest, item_label, active) in items {
468 if let Some(on_navigate) = on_demo_navigate {
469 button {
470 key: "{item_label}",
471 class: if active { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]" } else { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent" },
472 r#type: "button",
473 onclick: move |_| on_navigate.call(item_label),
474 "{item_label}"
475 }
476 } else {
477 Link {
478 key: "{item_label}",
479 class: if active { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]" } else { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent" },
480 to: dest,
481 "{item_label}"
482 }
483 }
484 }
485 }
486 }
487 }
488 }
489 }
490}
491
492#[component]
495fn ContentNavGroup(
496 #[props(default)] demo_active: Option<String>,
497 #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
498) -> Element {
499 let route = use_route::<Route>();
500 let active = |label: &str, route_active: bool| {
501 demo_active
502 .as_ref()
503 .map_or(route_active, |name| name == label)
504 };
505 let items = vec![
506 (
507 Route::AdminNotes {},
508 "笔记",
509 active(
510 "笔记",
511 matches!(
512 route,
513 Route::AdminNotes {} | Route::NewNote {} | Route::EditNote { .. }
514 ),
515 ),
516 ),
517 (
518 Route::AdminNotebooks {},
519 "笔记本",
520 active("笔记本", matches!(route, Route::AdminNotebooks {})),
521 ),
522 (
523 Route::Posts {},
524 "全部文章",
525 active("全部文章", matches!(route, Route::Posts {})),
526 ),
527 (
528 Route::PostsTrash {},
529 "回收站",
530 active("回收站", matches!(route, Route::PostsTrash {})),
531 ),
532 (
533 Route::AdminComments {},
534 "评论管理",
535 active(
536 "评论管理",
537 matches!(
538 route,
539 Route::AdminComments {} | Route::AdminCommentsPage { .. }
540 ),
541 ),
542 ),
543 ];
544 rsx! {
545 NavGroup { kind: NavGroupKind::Content, items, on_demo_navigate }
546 }
547}
548
549#[component]
552fn ToolsNavGroup(
553 #[props(default)] demo_active: Option<String>,
554 #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
555) -> Element {
556 let route = use_route::<Route>();
557 let active = |label: &str, route_active: bool| {
558 demo_active
559 .as_ref()
560 .map_or(route_active, |name| name == label)
561 };
562 let items = vec![
563 (
564 Route::SiteSettingsPage {},
565 "设置",
566 active("设置", matches!(route, Route::SiteSettingsPage {})),
567 ),
568 (
569 Route::Runner {},
570 "试运行",
571 active("试运行", matches!(route, Route::Runner {})),
572 ),
573 (
574 Route::Mcp {},
575 "MCP",
576 active("MCP", matches!(route, Route::Mcp {})),
577 ),
578 (
579 Route::Logs {},
580 "日志",
581 active("日志", matches!(route, Route::Logs {})),
582 ),
583 (
584 Route::System {},
585 "系统",
586 active("系统", matches!(route, Route::System {})),
587 ),
588 ];
589 rsx! {
590 NavGroup { kind: NavGroupKind::Tools, items, on_demo_navigate }
591 }
592}