yggdrasil/src/pages/post_detail.rs
xfy b6cabe489f feat: migrate frontend to database-driven posts
- Replace hardcoded POSTS with API-driven data in home, archives, tags
- Add post detail page /post/:slug with HTML rendering
- Add admin posts management page with list and soft delete
- Update dashboard with real stats from database
- Add admin navigation for posts management
- Fix PartialEq derives for Post, Tag, PostStats models
- Use use_resource and use_memo for data fetching with proper loading states
2026-06-02 17:33:28 +08:00

117 lines
6.3 KiB
Rust

use dioxus::prelude::*;
use crate::api::posts::{get_post_by_slug, SinglePostResponse};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::router::Route;
use crate::theme::ThemeToggle;
#[component]
pub fn PostDetail(slug: String) -> Element {
let route = use_route::<Route>();
let slug_clone = slug.clone();
let post_res = use_resource(move || get_post_by_slug(slug_clone.clone()));
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::Tags {}) || matches!(route, Route::TagDetail { .. }) },
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::Search {}) },
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
];
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
match &*post_res.read() {
Some(Ok(SinglePostResponse { post: Some(post) })) => {
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
rsx! {
article { class: "py-6",
header { class: "mb-8",
h1 { class: "text-3xl md:text-4xl font-bold text-gray-900 dark:text-[#dadadb] leading-tight",
"{post.title}"
}
div { class: "mt-4 flex items-center gap-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
span { "{date_str}" }
if !post.tags.is_empty() {
span { "·" }
for tag in post.tags.clone().into_iter() {
a {
class: "hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors",
href: "/tags/{tag}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/tags/{}", tag).as_str());
},
"{tag}"
}
}
}
}
}
div {
class: "prose dark:prose-invert max-w-none text-gray-800 dark:text-[#c9cacc] leading-relaxed",
dangerous_inner_html: "{post.content_html.as_deref().unwrap_or(\"\")}"
}
div { class: "mt-12 pt-6 border-t border-gray-200 dark:border-[#333]",
button {
class: "text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors",
onclick: move |_| {
let _ = dioxus::router::navigator().push("/");
},
"← 返回首页"
}
}
}
}
}
Some(Ok(SinglePostResponse { post: None })) => {
rsx! {
div { class: "text-center py-20",
h2 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
"文章不存在"
}
p { class: "text-gray-500 dark:text-[#9b9c9d] mb-6",
"这篇文章可能已被删除或移动。"
}
button {
class: "px-6 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full font-medium hover:opacity-80 transition-opacity",
onclick: move |_| {
let _ = dioxus::router::navigator().push("/");
},
"返回首页"
}
}
}
}
Some(Err(e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败: {e}"
}
}
}
None => {
rsx! {
div { class: "animate-pulse py-6 space-y-4",
div { class: "h-10 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded mt-8" }
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-2/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
Footer {}
}
}
}