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 nav_items_top = vec![(Route::Admin {}, "仪表盘"), (Route::Write {}, "写文章")];
57 let nav_items_bottom = vec![(Route::Assets {}, "素材"), (Route::FriendsAdmin {}, "友链")];
58
59 let is_write_route =
60 matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
61 let internal_scroll_route = is_write_route
66 || matches!(route, Route::SiteSettingsPage {})
67 || matches!(route, Route::Logs {});
68
69 let card_overflow = if internal_scroll_route {
74 "overflow-hidden"
75 } else {
76 "overflow-y-auto"
77 };
78 let main_class = if internal_scroll_route {
79 "flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
80 } else {
81 "flex-1 w-full max-w-7xl mx-auto px-6 py-12"
82 };
83
84 let root_class =
85 "min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans";
86
87 let is_profile_route = matches!(route, Route::Profile {});
89 let chip_state_class = if is_profile_route {
90 "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
91 } else {
92 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
93 };
94 let side_user = (ctx.user)();
96
97 let nav_content = rsx! {
98 aside { class: "w-48 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-3 bg-[var(--color-paper-entry)]",
99 div { class: "mb-8 px-3",
101 Link {
108 class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
109 to: NavigationTarget::<Route>::External("/".to_string()),
110 "Yggdrasil."
111 }
112 }
113 nav { class: "flex-1 flex flex-col gap-2",
115 for (dest, label) in nav_items_top {
116 {
117 let is_active = route == dest || (label == "写文章" && is_write_route);
118 let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
119 let text_class = if is_active {
120 "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
121 } else {
122 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
123 };
124 rsx! {
125 Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
126 }
127 }
128 }
129 ContentNavGroup {}
131 for (dest, label) in nav_items_bottom {
132 {
133 let is_active = route == dest || (label == "写文章" && is_write_route);
134 let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
135 let text_class = if is_active {
136 "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
137 } else {
138 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
139 };
140 rsx! {
141 Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
142 }
143 }
144 }
145 ToolsNavGroup {}
146 }
147 div { class: "mt-auto pt-4 border-t border-[var(--color-paper-border)] flex flex-col gap-1",
149 if let Some(user) = side_user {
150 Link {
151 class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
152 to: Route::Profile {},
153 UserAvatar {
154 name: user.display_label().to_string(),
155 avatar_url: user.avatar_url.clone(),
156 class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
157 }
158 span { class: "truncate", "{user.display_label()}" }
159 }
160 } else {
161 div { class: "flex items-center gap-2.5 px-3 py-2",
163 div { class: "w-7 h-7 rounded-full bg-[var(--color-paper-theme)] animate-pulse flex-shrink-0" }
164 div { class: "h-3.5 w-16 rounded bg-[var(--color-paper-theme)] animate-pulse" }
165 }
166 }
167 div { class: "flex items-center justify-between px-3",
168 ThemeToggle {}
169 button {
170 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",
171 onclick: move |_| {
172 spawn(async move {
173 let _ = logout().await;
174 ctx.user.set(None);
175 ctx.checked.set(false);
176 let _ = navigator.push(Route::Login {});
177 });
178 },
179 "退出"
180 }
181 }
182 }
183 }
184 };
185
186 let skeleton_route = route.clone();
187 match ((ctx.checked)(), (ctx.user)()) {
188 (true, Some(_)) => {
189 rsx! {
190 div { class: "{root_class}",
191 {nav_content}
192 div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
193 div { class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] {card_overflow} relative flex flex-col",
194 main { class: "{main_class}",
195 SuspenseBoundary {
201 fallback: move |_| rsx! {
202 div { class: "flex-1 min-h-0 flex flex-col animate-pulse", {admin_route_skeleton(&skeleton_route)} }
203 },
204 Outlet::<Route> {}
205 }
206 }
207 }
208 }
209 }
210 }
211 }
212 _ => {
213 rsx! {
214 div { class: "{root_class}",
215 {nav_content}
216 div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
217 div { class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] overflow-hidden relative flex flex-col",
218 main { class: "{main_class}",
219 div { class: "flex-1 min-h-0 flex flex-col animate-pulse",
222 {admin_route_skeleton(&route)}
223 }
224 }
225 }
226 }
227 }
228 }
229 }
230 }
231}
232
233fn admin_route_skeleton(route: &Route) -> Element {
235 match route {
236 Route::Admin {} => rsx! {
237 AdminDashboardSkeleton {}
238 },
239 Route::Write {} | Route::WriteEdit { .. } => rsx! {
240 WriteSkeleton {}
241 },
242 Route::Posts {} => rsx! {
243 PostsSkeleton {}
244 },
245 Route::PostsTrash {} => rsx! {
246 PostsTrashSkeleton {}
247 },
248 Route::Assets {} => rsx! {
249 AssetsSkeleton {}
250 },
251 Route::FriendsAdmin {} => rsx! {
252 FriendsAdminSkeleton {}
253 },
254 Route::AdminComments {} | Route::AdminCommentsPage { .. } => {
255 rsx! {
256 AdminCommentsSkeleton {}
257 }
258 }
259 Route::System {} => rsx! {
260 SystemSkeleton {}
261 },
262 Route::Runner {} => rsx! {
263 RunnerSkeleton {}
264 },
265 Route::Mcp {} => rsx! {
266 McpSkeleton {}
267 },
268 Route::Logs {} => rsx! {
269 LogsSkeleton {}
270 },
271 Route::SiteSettingsPage {} => rsx! {
272 SettingsAdminSkeleton {}
273 },
274 Route::Profile {} => rsx! {
275 ProfileSkeleton {}
276 },
277 Route::PostPreview { .. } => rsx! {
278 PostPreviewSkeleton {}
279 },
280 _ => rsx! {
281 AdminDashboardSkeleton {}
282 },
283 }
284}
285
286#[component]
293fn ContentNavGroup() -> Element {
294 let route = use_route::<Route>();
295 fn in_group(route: &Route) -> bool {
297 matches!(
298 route,
299 Route::Posts {}
300 | Route::PostsTrash {}
301 | Route::AdminComments {}
302 | Route::AdminCommentsPage { .. }
303 )
304 }
305 let group_active = in_group(&route);
306 let mut expanded = use_signal(|| group_active);
307
308 use_effect(move || {
311 let current = router().current::<Route>();
312 if in_group(¤t) {
313 expanded.set(true);
314 }
315 });
316
317 let chevron_rotate = if expanded() { "rotate-180" } else { "" };
318 let parent_text_class = if group_active {
321 "text-[var(--color-paper-primary)] border border-transparent"
322 } else {
323 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
324 };
325
326 rsx! {
327 div { class: "flex flex-col gap-1",
328 button {
329 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}",
330 onclick: move |_| expanded.set(!expanded()),
331 span { "内容管理" }
332 svg {
333 class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
334 view_box: "0 0 24 24",
335 fill: "none",
336 stroke: "currentColor",
337 stroke_width: "2",
338 path {
339 stroke_linecap: "round",
340 stroke_linejoin: "round",
341 d: "M19 9l-7 7-7-7",
342 }
343 }
344 }
345 div {
347 class: "grid transition-all duration-300 ease-in-out",
348 style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
349 div { class: "overflow-hidden min-h-0",
350 div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
352 for (dest, label, active) in [
353 (Route::Posts {}, "全部文章", matches!(route, Route::Posts {})),
354 (Route::PostsTrash {}, "回收站", matches!(route, Route::PostsTrash {})),
355 (
356 Route::AdminComments {},
357 "评论管理",
358 matches!(route, Route::AdminComments {} | Route::AdminCommentsPage { .. }),
359 ),
360 ]
361 {
362 Link {
363 key: "{label}",
364 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" },
365 to: dest,
366 "{label}"
367 }
368 }
369 }
370 }
371 }
372 }
373 }
374}
375
376#[component]
383fn ToolsNavGroup() -> Element {
384 let route = use_route::<Route>();
385 fn in_group(route: &Route) -> bool {
387 matches!(
388 route,
389 Route::Runner {}
390 | Route::Mcp {}
391 | Route::System {}
392 | Route::SiteSettingsPage {}
393 | Route::Logs {}
394 )
395 }
396 let group_active = in_group(&route);
397 let mut expanded = use_signal(|| group_active);
398
399 use_effect(move || {
402 let current = router().current::<Route>();
403 if in_group(¤t) {
404 expanded.set(true);
405 }
406 });
407
408 let chevron_rotate = if expanded() { "rotate-180" } else { "" };
409 let parent_text_class = if group_active {
412 "text-[var(--color-paper-primary)] border border-transparent"
413 } else {
414 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
415 };
416
417 rsx! {
418 div { class: "flex flex-col gap-1",
419 button {
420 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}",
421 onclick: move |_| expanded.set(!expanded()),
422 span { "工具" }
423 svg {
424 class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
425 view_box: "0 0 24 24",
426 fill: "none",
427 stroke: "currentColor",
428 stroke_width: "2",
429 path {
430 stroke_linecap: "round",
431 stroke_linejoin: "round",
432 d: "M19 9l-7 7-7-7",
433 }
434 }
435 }
436 div {
438 class: "grid transition-all duration-300 ease-in-out",
439 style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
440 div { class: "overflow-hidden min-h-0",
441 div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
443 for (dest, label, active) in [
444 (
445 Route::SiteSettingsPage {},
446 "设置",
447 matches!(route, Route::SiteSettingsPage {}),
448 ),
449 (Route::Runner {}, "试运行", matches!(route, Route::Runner {})),
450 (Route::Mcp {}, "MCP", matches!(route, Route::Mcp {})),
451 (Route::Logs {}, "日志", matches!(route, Route::Logs {})),
452 (Route::System {}, "系统", matches!(route, Route::System {})),
453 ]
454 {
455 Link {
456 key: "{label}",
457 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" },
458 to: dest,
459 "{label}"
460 }
461 }
462 }
463 }
464 }
465 }
466 }
467}