diff --git a/src/components/mod.rs b/src/components/mod.rs index 49d6429..1c76c23 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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; diff --git a/src/components/post/breadcrumbs.rs b/src/components/post/breadcrumbs.rs new file mode 100644 index 0000000..648eced --- /dev/null +++ b/src/components/post/breadcrumbs.rs @@ -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}" } + } + } +} diff --git a/src/components/post/mod.rs b/src/components/post/mod.rs new file mode 100644 index 0000000..c12d412 --- /dev/null +++ b/src/components/post/mod.rs @@ -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; diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs new file mode 100644 index 0000000..35e24b1 --- /dev/null +++ b/src/components/post/post_content.rs @@ -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(©button); + } + } + } + } + } + } + }); + + rsx! { + div { + class: "post-content md-content", + dangerous_inner_html: "{content_html}" + } + } +} diff --git a/src/components/post/post_cover.rs b/src/components/post/post_cover.rs new file mode 100644 index 0000000..cc1d096 --- /dev/null +++ b/src/components/post/post_cover.rs @@ -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" + } + } + } +} diff --git a/src/components/post/post_footer.rs b/src/components/post/post_footer.rs new file mode 100644 index 0000000..aa53484 --- /dev/null +++ b/src/components/post/post_footer.rs @@ -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" + } + } + } + } +} diff --git a/src/components/post/post_header.rs b/src/components/post/post_header.rs new file mode 100644 index 0000000..d9f2997 --- /dev/null +++ b/src/components/post/post_header.rs @@ -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() } + } + } +} diff --git a/src/components/post/post_meta.rs b/src/components/post/post_meta.rs new file mode 100644 index 0000000..d579b61 --- /dev/null +++ b/src/components/post/post_meta.rs @@ -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" } + } + } +} diff --git a/src/components/post/post_nav_links.rs b/src/components/post/post_nav_links.rs new file mode 100644 index 0000000..99b7a1f --- /dev/null +++ b/src/components/post/post_nav_links.rs @@ -0,0 +1,40 @@ +use dioxus::prelude::*; + +use crate::models::post::PostNav; + +#[component] +pub fn PostNavLinks(prev: Option, next: Option) -> 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" } + } + } + } +} diff --git a/src/components/post/post_toc.rs b/src/components/post/post_toc.rs new file mode 100644 index 0000000..ffb0fb3 --- /dev/null +++ b/src/components/post/post_toc.rs @@ -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}" + } + } + } +} diff --git a/src/pages/post_detail.rs b/src/pages/post_detail.rs index a8e1444..4f8215d 100644 --- a/src/pages/post_detail.rs +++ b/src/pages/post_detail.rs @@ -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" } } } }