feat(components): add post page components (header, toc, content, footer, nav)

This commit is contained in:
xfy 2026-06-02 18:21:25 +08:00
parent a07f6ca51b
commit 6b1f2e27c9
11 changed files with 281 additions and 43 deletions

View File

@ -4,5 +4,6 @@ pub mod footer;
pub mod header;
pub mod nav;
pub mod page_layout;
pub mod post;
pub mod post_card;
pub mod write_skeleton;

View File

@ -0,0 +1,34 @@
use dioxus::prelude::*;
#[component]
pub fn Breadcrumbs(title: String) -> Element {
rsx! {
nav {
class: "breadcrumbs",
role: "navigation",
aria_label: "Breadcrumb",
a {
href: "/",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push("/");
},
"Home"
}
svg {
xmlns: "http://www.w3.org/2000/svg",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
class: "feather feather-chevron-right",
width: "16",
height: "16",
polyline { points: "9 18 15 12 9 6" }
}
span { "{title}" }
}
}
}

View File

@ -0,0 +1,8 @@
pub mod breadcrumbs;
pub mod post_content;
pub mod post_cover;
pub mod post_footer;
pub mod post_header;
pub mod post_meta;
pub mod post_nav_links;
pub mod post_toc;

View File

@ -0,0 +1,39 @@
use dioxus::prelude::*;
#[component]
pub fn PostContent(content_html: String) -> Element {
#[cfg(target_arch = "wasm32")]
use_effect(move || {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
// Add copy buttons to all code blocks
if let Ok(elements) = document.query_selector_all("pre > code") {
for i in 0..elements.length() {
if let Some(codeblock) = elements.item(i) {
if let Some(parent) = codeblock.parent_element() {
// Check if button already exists
if parent.query_selector(".copy-code").ok().flatten().is_some() {
continue;
}
let copybutton = document.create_element("button").unwrap();
copybutton.set_class_name("copy-code");
copybutton.set_text_content(Some("copy"));
copybutton.set_attribute("aria-label", "Copy code").unwrap();
let _ = parent.append_child(&copybutton);
}
}
}
}
}
}
});
rsx! {
div {
class: "post-content md-content",
dangerous_inner_html: "{content_html}"
}
}
}

View File

@ -0,0 +1,14 @@
use dioxus::prelude::*;
#[component]
pub fn PostCover(src: String) -> Element {
rsx! {
figure { class: "entry-cover",
img {
loading: "eager",
src: "{src}",
alt: "Cover image"
}
}
}
}

View File

@ -0,0 +1,46 @@
use dioxus::prelude::*;
use crate::components::post::post_nav_links::PostNavLinks;
use crate::models::post::Post;
#[component]
pub fn PostFooter(post: Post) -> Element {
let tags = post.tags.clone();
rsx! {
footer { class: "post-footer",
if !tags.is_empty() {
ul { class: "post-tags",
for tag in tags.into_iter() {
li {
a {
href: "/tags/{tag}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/tags/{}", tag));
},
"{tag}"
}
}
}
}
}
if post.prev_post.is_some() || post.next_post.is_some() {
PostNavLinks {
prev: post.prev_post,
next: post.next_post
}
}
div { class: "back-to-home",
button {
onclick: move |_| {
let _ = dioxus::router::navigator().push("/");
},
"← Back to Home"
}
}
}
}
}

View File

@ -0,0 +1,39 @@
use dioxus::prelude::*;
use crate::components::post::breadcrumbs::Breadcrumbs;
use crate::components::post::post_meta::PostMeta;
use crate::models::post::{Post, PostStatus};
#[component]
pub fn PostHeader(post: Post) -> Element {
rsx! {
header { class: "post-header",
Breadcrumbs { title: post.title.clone() }
h1 { class: "post-title",
"{post.title}"
if post.status == PostStatus::Draft {
span {
class: "entry-hint",
title: "Draft",
svg {
xmlns: "http://www.w3.org/2000/svg",
height: "35",
view_box: "0 -960 960 960",
fill: "currentColor",
path {
d: "M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z"
}
}
}
}
}
if let Some(summary) = &post.summary {
div { class: "post-description", "{summary}" }
}
PostMeta { post: post.clone() }
}
}
}

View File

@ -0,0 +1,16 @@
use dioxus::prelude::*;
use crate::models::post::Post;
#[component]
pub fn PostMeta(post: Post) -> Element {
rsx! {
div { class: "post-meta",
span { "{post.formatted_date()}" }
span { "·" }
span { "{post.reading_time} min read" }
span { "·" }
span { "{post.word_count} words" }
}
}
}

View File

@ -0,0 +1,40 @@
use dioxus::prelude::*;
use crate::models::post::PostNav;
#[component]
pub fn PostNavLinks(prev: Option<PostNav>, next: Option<PostNav>) -> Element {
rsx! {
nav { class: "paginav",
if let Some(prev_post) = prev {
a {
class: "prev",
href: "/post/{prev_post.slug}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/post/{}", prev_post.slug));
},
span { class: "title", "« Prev" }
span { class: "post-title-nav", "{prev_post.title}" }
}
} else {
span { class: "prev" }
}
if let Some(next_post) = next {
a {
class: "next",
href: "/post/{next_post.slug}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/post/{}", next_post.slug));
},
span { class: "title", "Next »" }
span { class: "post-title-nav", "{next_post.title}" }
}
} else {
span { class: "next" }
}
}
}
}

View File

@ -0,0 +1,18 @@
use dioxus::prelude::*;
#[component]
pub fn PostToc(toc_html: String) -> Element {
rsx! {
details { class: "toc",
summary {
accesskey: "c",
title: "(Alt + C)",
span { class: "title", "Table of Contents" }
}
div {
class: "inner",
dangerous_inner_html: "{toc_html}"
}
}
}
}

View File

@ -3,6 +3,11 @@ use dioxus::prelude::*;
use crate::api::posts::{get_post_by_slug, SinglePostResponse};
use crate::components::nav::use_nav_items;
use crate::components::page_layout::PageLayout;
use crate::components::post::post_content::PostContent;
use crate::components::post::post_cover::PostCover;
use crate::components::post::post_footer::PostFooter;
use crate::components::post::post_header::PostHeader;
use crate::components::post::post_toc::PostToc;
use crate::hooks::delayed_loading::use_delayed_loading;
use crate::router::Route;
@ -18,59 +23,37 @@ pub fn PostDetail(slug: String) -> Element {
PageLayout { nav_items,
match &*post_res.read() {
Some(Ok(SinglePostResponse { post: Some(post) })) => {
let date_str = post.formatted_date();
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}"
}
}
}
}
article { class: "post-single",
PostHeader { post: post.clone() }
if let Some(cover) = &post.cover_image {
PostCover { src: cover.clone() }
}
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(\"\")}"
if let Some(toc) = &post.toc_html {
PostToc { toc_html: toc.clone() }
}
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("/");
},
"← 返回首页"
}
PostContent {
content_html: post.content_html.clone().unwrap_or_default()
}
PostFooter { post: post.clone() }
}
}
}
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",
h2 { class: "text-2xl font-bold text-paper-primary mb-4",
"文章不存在"
}
p { class: "text-gray-500 dark:text-[#9b9c9d] mb-6",
p { class: "text-paper-secondary 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",
class: "px-6 py-2 bg-paper-primary text-paper-theme rounded-full font-medium hover:opacity-80 transition-opacity",
onclick: move |_| {
let _ = dioxus::router::navigator().push("/");
},
@ -89,11 +72,11 @@ pub fn PostDetail(slug: String) -> Element {
None => {
rsx! {
div { class: if show_skeleton() { "animate-pulse py-6 space-y-4" } else { "py-6 space-y-4 opacity-0" },
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" }
div { class: "h-10 w-3/4 bg-paper-tertiary rounded" }
div { class: "h-4 w-32 bg-paper-tertiary rounded" }
div { class: "h-4 w-full bg-paper-tertiary rounded mt-8" }
div { class: "h-4 w-full bg-paper-tertiary rounded" }
div { class: "h-4 w-2/3 bg-paper-tertiary rounded" }
}
}
}