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
This commit is contained in:
parent
df339cb084
commit
b6cabe489f
@ -46,6 +46,11 @@ pub fn AdminLayout() -> Element {
|
||||
label: "写文章",
|
||||
is_active: matches!(route, Route::Write {}),
|
||||
},
|
||||
NavItemConfig {
|
||||
href: "/admin/posts",
|
||||
label: "管理文章",
|
||||
is_active: matches!(route, Route::Posts {}),
|
||||
},
|
||||
NavItemConfig {
|
||||
href: "/",
|
||||
label: "前台",
|
||||
|
||||
@ -26,7 +26,7 @@ impl PostStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub id: i32,
|
||||
pub author_id: i32,
|
||||
@ -42,14 +42,14 @@ pub struct Post {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub post_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PostStats {
|
||||
pub total: i64,
|
||||
pub drafts: i64,
|
||||
|
||||
@ -1,16 +1,36 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::pages::home::{Post, POSTS};
|
||||
use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse};
|
||||
use crate::models::post::Post;
|
||||
|
||||
#[component]
|
||||
pub fn Admin() -> Element {
|
||||
let stats_res = use_resource(get_post_stats);
|
||||
let posts_res = use_resource(list_posts);
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-8",
|
||||
// 统计卡片
|
||||
div { class: "grid grid-cols-1 md:grid-cols-3 gap-6",
|
||||
StatCard { value: POSTS.len().to_string(), label: "文章总数" }
|
||||
StatCard { value: "0".to_string(), label: "草稿数" }
|
||||
StatCard { value: POSTS.len().to_string(), label: "已发布" }
|
||||
match &*stats_res.read() {
|
||||
Some(Ok(PostStatsResponse { stats })) => {
|
||||
rsx! {
|
||||
StatCard { value: stats.total.to_string(), label: "文章总数" }
|
||||
StatCard { value: stats.drafts.to_string(), label: "草稿数" }
|
||||
StatCard { value: stats.published.to_string(), label: "已发布" }
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
rsx! {
|
||||
for _ in 0..3 {
|
||||
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center space-y-3 animate-pulse",
|
||||
div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷操作
|
||||
@ -23,10 +43,9 @@ pub fn Admin() -> Element {
|
||||
"写文章"
|
||||
}
|
||||
button {
|
||||
class: "bg-gray-200 dark:bg-[#333] text-gray-700 dark:text-[#dadadb] rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity",
|
||||
class: "bg-gray-200 dark:bg-[#333] text-gray-700 dark:text-[#dadadb] rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer",
|
||||
onclick: move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::window().map(|w| w.alert_with_message("开发中").ok());
|
||||
dioxus::router::navigator().push("/admin/posts");
|
||||
},
|
||||
"管理文章"
|
||||
}
|
||||
@ -37,9 +56,27 @@ pub fn Admin() -> Element {
|
||||
h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
|
||||
"最近文章"
|
||||
}
|
||||
div { class: "space-y-0",
|
||||
for post in POSTS.iter().take(5) {
|
||||
RecentPostItem { post: post.clone() }
|
||||
match &*posts_res.read() {
|
||||
Some(Ok(PostListResponse { posts })) => {
|
||||
rsx! {
|
||||
div { class: "space-y-0",
|
||||
for post in posts.iter().take(5) {
|
||||
RecentPostItem { post: post.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
rsx! {
|
||||
div { class: "space-y-4 animate-pulse",
|
||||
for _ in 0..5 {
|
||||
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
|
||||
div { class: "h-4 w-[45%] bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
div { class: "h-3 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,13 +100,33 @@ fn StatCard(value: String, label: String) -> Element {
|
||||
|
||||
#[component]
|
||||
fn RecentPostItem(post: Post) -> Element {
|
||||
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());
|
||||
let status_label = if post.status == crate::models::post::PostStatus::Published {
|
||||
"已发布"
|
||||
} else {
|
||||
"草稿"
|
||||
};
|
||||
let status_class = if post.status == crate::models::post::PostStatus::Published {
|
||||
"text-green-600 dark:text-green-400"
|
||||
} else {
|
||||
"text-gray-400 dark:text-[#9b9c9d]"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
|
||||
span { class: "text-gray-700 dark:text-[#dadadb]",
|
||||
"{post.title}"
|
||||
div { class: "flex items-center gap-3",
|
||||
span { class: "text-gray-700 dark:text-[#dadadb]",
|
||||
"{post.title}"
|
||||
}
|
||||
span { class: "text-xs {status_class}",
|
||||
"{status_label}"
|
||||
}
|
||||
}
|
||||
span { class: "text-sm text-gray-400 dark:text-[#9b9c9d]",
|
||||
"{post.date}"
|
||||
"{date_str}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
pub mod dashboard;
|
||||
pub mod posts;
|
||||
pub mod write;
|
||||
|
||||
pub use dashboard::Admin;
|
||||
pub use write::Write;
|
||||
pub use posts::Posts;
|
||||
pub use write::Write;
|
||||
150
src/pages/admin/posts.rs
Normal file
150
src/pages/admin/posts.rs
Normal file
@ -0,0 +1,150 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::posts::{delete_post, list_posts, CreatePostResponse, PostListResponse};
|
||||
use crate::models::post::{Post, PostStatus};
|
||||
|
||||
#[component]
|
||||
pub fn Posts() -> Element {
|
||||
let mut posts_res = use_resource(list_posts);
|
||||
let mut deleting = use_signal(|| None::<i32>);
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-6",
|
||||
div { class: "flex items-center justify-between",
|
||||
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
"文章管理"
|
||||
}
|
||||
button {
|
||||
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
|
||||
onclick: move |_| {
|
||||
dioxus::router::navigator().push("/admin/write");
|
||||
},
|
||||
"+ 写文章"
|
||||
}
|
||||
}
|
||||
|
||||
match &*posts_res.read() {
|
||||
Some(Ok(PostListResponse { posts })) => {
|
||||
if posts.is_empty() {
|
||||
rsx! {
|
||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
||||
"暂无文章"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {
|
||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
|
||||
th { class: "px-4 py-3 font-medium", "标题" }
|
||||
th { class: "px-4 py-3 font-medium w-24", "状态" }
|
||||
th { class: "px-4 py-3 font-medium w-32", "日期" }
|
||||
th { class: "px-4 py-3 font-medium w-24 text-right", "操作" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for post in posts.iter() {
|
||||
PostRow {
|
||||
post: post.clone(),
|
||||
deleting: deleting() == Some(post.id),
|
||||
on_delete: move |id| {
|
||||
deleting.set(Some(id));
|
||||
spawn(async move {
|
||||
match delete_post(id).await {
|
||||
Ok(CreatePostResponse { success: true, .. }) => {
|
||||
posts_res.restart();
|
||||
}
|
||||
Ok(CreatePostResponse { success: false, message, .. }) => {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::window().map(|w| w.alert_with_message(&message).ok());
|
||||
}
|
||||
Err(e) => {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::window().map(|w| w.alert_with_message(&format!("删除失败: {}", e)).ok());
|
||||
}
|
||||
}
|
||||
deleting.set(None);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
rsx! {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败: {e}"
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
rsx! {
|
||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] animate-pulse",
|
||||
for _ in 0..5 {
|
||||
div { class: "flex items-center px-4 py-3 border-b border-gray-100 dark:border-[#333] last:border-0",
|
||||
div { class: "h-4 w-1/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
div { class: "ml-auto h-4 w-16 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
|
||||
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());
|
||||
|
||||
let (status_label, status_class) = if post.status == PostStatus::Published {
|
||||
("已发布", "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300")
|
||||
} else {
|
||||
("草稿", "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]")
|
||||
};
|
||||
|
||||
rsx! {
|
||||
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
|
||||
td { class: "px-4 py-3",
|
||||
a {
|
||||
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
||||
href: "/post/{post.slug}",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
dioxus::router::navigator().push(format!("/post/{}", post.slug).as_str());
|
||||
},
|
||||
"{post.title}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3",
|
||||
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {status_class}",
|
||||
"{status_label}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
|
||||
"{date_str}"
|
||||
}
|
||||
td { class: "px-4 py-3 text-right",
|
||||
button {
|
||||
class: if deleting {
|
||||
"text-xs text-gray-400 cursor-not-allowed"
|
||||
} else {
|
||||
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"
|
||||
},
|
||||
disabled: deleting,
|
||||
onclick: move |_| on_delete.call(post.id),
|
||||
if deleting { "删除中..." } else { "删除" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,60 +1,22 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||
use crate::components::header::{Header, NavItemConfig};
|
||||
use crate::components::footer::Footer;
|
||||
use crate::models::post::Post;
|
||||
use crate::router::Route;
|
||||
use crate::theme::ThemeToggle;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Post {
|
||||
pub title: &'static str,
|
||||
pub date: &'static str,
|
||||
pub slug: &'static str,
|
||||
}
|
||||
|
||||
const POSTS: &[Post] = &[
|
||||
Post {
|
||||
title: "开始使用 Rust 构建 Web 应用",
|
||||
date: "2026-05-20",
|
||||
slug: "rust-web-app",
|
||||
},
|
||||
Post {
|
||||
title: "Tailwind CSS 的设计理念与实践",
|
||||
date: "2026-05-15",
|
||||
slug: "tailwind-css",
|
||||
},
|
||||
Post {
|
||||
title: "PostgreSQL 在 Rust 项目中的最佳实践",
|
||||
date: "2026-05-10",
|
||||
slug: "postgresql-rust",
|
||||
},
|
||||
Post {
|
||||
title: "暗色模式的设计思考",
|
||||
date: "2026-05-05",
|
||||
slug: "dark-mode-design",
|
||||
},
|
||||
Post {
|
||||
title: "博客系统的架构演进",
|
||||
date: "2026-04-28",
|
||||
slug: "blog-architecture",
|
||||
},
|
||||
Post {
|
||||
title: "Dioxus 0.7 新特性一览",
|
||||
date: "2026-04-20",
|
||||
slug: "dioxus-07",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct YearGroup {
|
||||
year: &'static str,
|
||||
year: String,
|
||||
months: Vec<MonthGroup>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct MonthGroup {
|
||||
month: &'static str,
|
||||
month_en: &'static str,
|
||||
month: String,
|
||||
month_en: String,
|
||||
posts: Vec<Post>,
|
||||
}
|
||||
|
||||
@ -62,11 +24,16 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
||||
let mut years: Vec<YearGroup> = vec![];
|
||||
|
||||
for post in posts {
|
||||
let parts: Vec<&str> = post.date.split('-').collect();
|
||||
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());
|
||||
|
||||
let parts: Vec<&str> = date_str.split('-').collect();
|
||||
if parts.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
let year = parts[0];
|
||||
let year = parts[0].to_string();
|
||||
let month_num = parts[1];
|
||||
let month_en = match month_num {
|
||||
"01" => "January",
|
||||
@ -93,8 +60,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
||||
}
|
||||
}
|
||||
yg.months.push(MonthGroup {
|
||||
month: month_en,
|
||||
month_en,
|
||||
month: month_en.to_string(),
|
||||
month_en: month_en.to_string(),
|
||||
posts: vec![post.clone()],
|
||||
});
|
||||
continue;
|
||||
@ -103,8 +70,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
||||
years.push(YearGroup {
|
||||
year,
|
||||
months: vec![MonthGroup {
|
||||
month: month_en,
|
||||
month_en,
|
||||
month: month_en.to_string(),
|
||||
month_en: month_en.to_string(),
|
||||
posts: vec![post.clone()],
|
||||
}],
|
||||
});
|
||||
@ -116,6 +83,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
||||
#[component]
|
||||
pub fn Archives() -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let posts_res = use_resource(list_published_posts);
|
||||
|
||||
let nav_items = vec![
|
||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
|
||||
@ -124,8 +93,6 @@ pub fn Archives() -> Element {
|
||||
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
|
||||
];
|
||||
|
||||
let grouped = group_posts(POSTS);
|
||||
|
||||
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 {} } }
|
||||
@ -134,14 +101,57 @@ pub fn Archives() -> Element {
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
"归档"
|
||||
}
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{POSTS.len()}" }
|
||||
" 篇文章"
|
||||
match &*posts_res.read() {
|
||||
Some(Ok(PostListResponse { posts })) => {
|
||||
rsx! {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{posts.len()}" }
|
||||
" 篇文章"
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
rsx! {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"加载中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for year_group in grouped.iter() {
|
||||
YearSection { year_group: year_group.clone() }
|
||||
match &*posts_res.read() {
|
||||
Some(Ok(PostListResponse { posts })) => {
|
||||
let grouped = group_posts(posts);
|
||||
rsx! {
|
||||
for year_group in grouped.iter() {
|
||||
YearSection { year_group: year_group.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
rsx! {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败: {e}"
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
rsx! {
|
||||
div { class: "space-y-8 animate-pulse",
|
||||
for _ in 0..2 {
|
||||
div { class: "space-y-4",
|
||||
div { class: "h-8 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
div { class: "space-y-2",
|
||||
for _ in 0..3 {
|
||||
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Footer {}
|
||||
@ -166,14 +176,14 @@ fn YearSection(year_group: YearGroup) -> Element {
|
||||
sup { class: "archive-count text-sm text-gray-400 dark:text-[#9b9c9d] ml-1", "{total}" }
|
||||
}
|
||||
for month_group in year_group.months.iter() {
|
||||
MonthSection { month_group: month_group.clone(), year: year_group.year }
|
||||
MonthSection { month_group: month_group.clone(), year: year_group.year.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MonthSection(month_group: MonthGroup, year: &'static str) -> Element {
|
||||
fn MonthSection(month_group: MonthGroup, year: String) -> Element {
|
||||
let count = month_group.posts.len();
|
||||
|
||||
rsx! {
|
||||
@ -199,13 +209,18 @@ fn MonthSection(month_group: MonthGroup, year: &'static str) -> Element {
|
||||
|
||||
#[component]
|
||||
fn ArchiveEntry(post: Post) -> Element {
|
||||
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! {
|
||||
div { class: "archive-entry relative py-1.5 my-2.5 group",
|
||||
h3 { class: "archive-entry-title text-base font-normal text-gray-900 dark:text-[#dadadb] m-0",
|
||||
"{post.title}"
|
||||
}
|
||||
div { class: "archive-meta text-sm text-gray-400 dark:text-[#9b9c9d] mt-1",
|
||||
"{post.date}"
|
||||
"{date_str}"
|
||||
}
|
||||
a {
|
||||
class: "entry-link absolute inset-0 z-10",
|
||||
|
||||
@ -1,67 +1,17 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||
use crate::components::header::{Header, NavItemConfig};
|
||||
use crate::components::footer::Footer;
|
||||
use crate::models::post::Post;
|
||||
use crate::router::Route;
|
||||
use crate::theme::ThemeToggle;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Post {
|
||||
pub title: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub date: &'static str,
|
||||
pub tags: &'static [&'static str],
|
||||
pub slug: &'static str,
|
||||
}
|
||||
|
||||
pub const POSTS: &[Post] = &[
|
||||
Post {
|
||||
title: "开始使用 Rust 构建 Web 应用",
|
||||
summary: "Rust 作为一门系统级编程语言,近年来在 Web 开发领域也展现出了强大的生命力。本文将介绍如何使用 Rust 和 Dioxus 框架构建现代化的全栈 Web 应用,从项目搭建到部署的完整流程。",
|
||||
date: "2026-05-20",
|
||||
tags: &["Rust", "Web"],
|
||||
slug: "rust-web-app",
|
||||
},
|
||||
Post {
|
||||
title: "Tailwind CSS 的设计理念与实践",
|
||||
summary: "Tailwind CSS 是一种实用优先的 CSS 框架,它改变了我们编写样式的方式。通过原子化的工具类,开发者可以快速构建出美观且一致的界面,而无需在 CSS 文件和 HTML 之间来回切换。",
|
||||
date: "2026-05-15",
|
||||
tags: &["CSS", "前端"],
|
||||
slug: "tailwind-css",
|
||||
},
|
||||
Post {
|
||||
title: "PostgreSQL 在 Rust 项目中的最佳实践",
|
||||
summary: "数据库是大多数 Web 应用的核心组件。本文探讨如何在 Rust 项目中高效地使用 PostgreSQL,包括连接池管理、异步查询、事务处理以及常见的性能优化技巧。",
|
||||
date: "2026-05-10",
|
||||
tags: &["数据库", "Rust"],
|
||||
slug: "postgresql-rust",
|
||||
},
|
||||
Post {
|
||||
title: "暗色模式的设计思考",
|
||||
summary: "暗色模式不仅仅是颜色的反转,它涉及到一整套设计系统的重新思考。从对比度到语义化颜色,暗色模式需要细致的打磨才能提供舒适的阅读体验。",
|
||||
date: "2026-05-05",
|
||||
tags: &["设计", "UI"],
|
||||
slug: "dark-mode-design",
|
||||
},
|
||||
Post {
|
||||
title: "博客系统的架构演进",
|
||||
summary: "从一个简单的静态页面到全栈应用,博客系统的架构经历了多次演进。本文记录了 Yggdrasil 博客从设计到实现的思考过程,以及每次迭代背后的决策依据。",
|
||||
date: "2026-04-28",
|
||||
tags: &["架构", "博客"],
|
||||
slug: "blog-architecture",
|
||||
},
|
||||
Post {
|
||||
title: "Dioxus 0.7 新特性一览",
|
||||
summary: "Dioxus 0.7 带来了许多令人兴奋的改进,包括更好的全栈支持、改进的路由系统和更流畅的开发体验。让我们一起看看这些新特性如何提升开发效率。",
|
||||
date: "2026-04-20",
|
||||
tags: &["Rust", "框架"],
|
||||
slug: "dioxus-07",
|
||||
},
|
||||
];
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let posts_res = use_resource(list_published_posts);
|
||||
|
||||
let nav_items = vec![
|
||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
|
||||
@ -75,8 +25,39 @@ pub fn Home() -> Element {
|
||||
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
|
||||
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
|
||||
HomeInfo {}
|
||||
for post in POSTS.iter() {
|
||||
PostEntry { post: post.clone() }
|
||||
match &*posts_res.read() {
|
||||
Some(Ok(PostListResponse { posts })) => {
|
||||
rsx! {
|
||||
for post in posts.iter() {
|
||||
PostEntry { post: post.clone() }
|
||||
}
|
||||
if posts.is_empty() {
|
||||
div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-20",
|
||||
"暂无文章"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
rsx! {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败: {e}"
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
rsx! {
|
||||
div { class: "space-y-6 py-4",
|
||||
for _ in 0..3 {
|
||||
div { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] animate-pulse",
|
||||
div { class: "h-7 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded mb-3" }
|
||||
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded mb-2" }
|
||||
div { class: "h-4 w-2/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Pagination {}
|
||||
}
|
||||
@ -101,9 +82,12 @@ fn HomeInfo() -> Element {
|
||||
|
||||
#[component]
|
||||
fn PostEntry(post: Post) -> Element {
|
||||
let tag_items = post.tags.to_vec();
|
||||
let post_slug = post.slug.clone();
|
||||
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());
|
||||
|
||||
let post_slug = post.slug;
|
||||
rsx! {
|
||||
article { class: "relative mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] hover:-translate-y-0.5 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-250",
|
||||
a {
|
||||
@ -117,16 +101,26 @@ fn PostEntry(post: Post) -> Element {
|
||||
"{post.title}"
|
||||
}
|
||||
div { class: "mt-2 text-sm text-gray-500 dark:text-[#9b9c9d] leading-relaxed line-clamp-2",
|
||||
"{post.summary}"
|
||||
"{post.summary.as_deref().unwrap_or(\"\")}"
|
||||
}
|
||||
div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
|
||||
span { "{post.date}" }
|
||||
span { "·" }
|
||||
for (i, tag) in tag_items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
span { "," }
|
||||
span { "{date_str}" }
|
||||
if !post.tags.is_empty() {
|
||||
span { "·" }
|
||||
for tag in post.tags.clone().into_iter() {
|
||||
span {
|
||||
a {
|
||||
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
|
||||
href: "/tags/{tag}",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
evt.stop_propagation();
|
||||
dioxus::router::navigator().push(format!("/tags/{}", tag).as_str());
|
||||
},
|
||||
"{tag}"
|
||||
}
|
||||
}
|
||||
}
|
||||
span { "{tag}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,5 +2,6 @@ pub mod admin;
|
||||
pub mod archives;
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod post_detail;
|
||||
pub mod register;
|
||||
pub mod tags;
|
||||
pub mod tags;
|
||||
116
src/pages/post_detail.rs
Normal file
116
src/pages/post_detail.rs
Normal file
@ -0,0 +1,116 @@
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,47 +1,17 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
|
||||
use crate::components::header::{Header, NavItemConfig};
|
||||
use crate::components::footer::Footer;
|
||||
use crate::pages::home::{Post, POSTS};
|
||||
use crate::models::post::Post;
|
||||
use crate::router::Route;
|
||||
use crate::theme::ThemeToggle;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct TagInfo {
|
||||
name: &'static str,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
fn collect_tags() -> Vec<TagInfo> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut counts: HashMap<&'static str, usize> = HashMap::new();
|
||||
for post in POSTS.iter() {
|
||||
for tag in post.tags.iter() {
|
||||
*counts.entry(*tag).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tags: Vec<TagInfo> = counts
|
||||
.into_iter()
|
||||
.map(|(name, count)| TagInfo { name, count })
|
||||
.collect();
|
||||
|
||||
tags.sort_by(|a, b| a.name.cmp(b.name));
|
||||
tags
|
||||
}
|
||||
|
||||
fn posts_for_tag(tag: &str) -> Vec<Post> {
|
||||
POSTS
|
||||
.iter()
|
||||
.filter(|p| p.tags.contains(&tag))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Tags() -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let tags_res = use_resource(list_tags);
|
||||
|
||||
let nav_items = vec![
|
||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
|
||||
@ -50,8 +20,23 @@ pub fn Tags() -> Element {
|
||||
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
|
||||
];
|
||||
|
||||
let tags = collect_tags();
|
||||
let total_posts = POSTS.len();
|
||||
let tags_data = use_memo(move || {
|
||||
match &*tags_res.read() {
|
||||
Some(Ok(TagListResponse { tags })) => Some(tags.clone()),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
let total_posts = use_memo(move || {
|
||||
match &*tags_res.read() {
|
||||
Some(Ok(TagListResponse { tags })) => tags.iter().map(|t| t.post_count).sum::<i64>(),
|
||||
_ => 0,
|
||||
}
|
||||
});
|
||||
|
||||
let has_error = use_memo(move || {
|
||||
matches!(&*tags_res.read(), Some(Err(_)))
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
||||
@ -61,29 +46,47 @@ pub fn Tags() -> Element {
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
"标签"
|
||||
}
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{tags.len()}" }
|
||||
" 个标签,"
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total_posts}" }
|
||||
" 篇文章"
|
||||
if tags_data().is_some() {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{tags_data().unwrap().len()}" }
|
||||
" 个标签,"
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total_posts()}" }
|
||||
" 篇文章"
|
||||
}
|
||||
} else {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"加载中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
ul { class: "flex flex-wrap gap-4 mt-6",
|
||||
for (name, count) in tags.into_iter().map(|t| (t.name, t.count)) {
|
||||
li {
|
||||
a {
|
||||
class: "inline-flex items-center px-3 py-1.5 text-base font-medium bg-gray-100 dark:bg-[#2e2e33] text-gray-700 dark:text-[#9b9c9d] rounded-lg hover:bg-gray-200 dark:hover:bg-[#333] transition-colors",
|
||||
href: "/tags/{name}",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
dioxus::router::navigator().push(format!("/tags/{}", name).as_str());
|
||||
},
|
||||
"{name}"
|
||||
sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{count}" }
|
||||
if has_error() {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败"
|
||||
}
|
||||
} else if tags_data().is_some() {
|
||||
ul { class: "flex flex-wrap gap-4 mt-6",
|
||||
for tag in tags_data().unwrap().into_iter() {
|
||||
li {
|
||||
a {
|
||||
class: "inline-flex items-center px-3 py-1.5 text-base font-medium bg-gray-100 dark:bg-[#2e2e33] text-gray-700 dark:text-[#9b9c9d] rounded-lg hover:bg-gray-200 dark:hover:bg-[#333] transition-colors",
|
||||
href: "/tags/{tag.name}",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
dioxus::router::navigator().push(format!("/tags/{}", tag.name).as_str());
|
||||
},
|
||||
"{tag.name}"
|
||||
sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{tag.post_count}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "flex flex-wrap gap-4 mt-6 animate-pulse",
|
||||
for _ in 0..8 {
|
||||
div { class: "h-8 w-16 bg-gray-200 dark:bg-[#2a2a2a] rounded-lg" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Footer {}
|
||||
@ -94,6 +97,9 @@ pub fn Tags() -> Element {
|
||||
#[component]
|
||||
pub fn TagDetail(tag: String) -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let tag_clone = tag.clone();
|
||||
let posts_res = use_resource(move || get_posts_by_tag(tag_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 {}) },
|
||||
@ -102,7 +108,23 @@ pub fn TagDetail(tag: String) -> Element {
|
||||
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
|
||||
];
|
||||
|
||||
let posts = posts_for_tag(&tag);
|
||||
let posts_data = use_memo(move || {
|
||||
match &*posts_res.read() {
|
||||
Some(Ok(PostListResponse { posts })) => Some(posts.clone()),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
let post_count = use_memo(move || {
|
||||
match &*posts_res.read() {
|
||||
Some(Ok(PostListResponse { posts })) => posts.len(),
|
||||
_ => 0,
|
||||
}
|
||||
});
|
||||
|
||||
let has_error = use_memo(move || {
|
||||
matches!(&*posts_res.read(), Some(Err(_)))
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
||||
@ -112,14 +134,36 @@ pub fn TagDetail(tag: String) -> Element {
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
"{tag}"
|
||||
}
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{posts.len()}" }
|
||||
" 篇文章"
|
||||
if post_count() > 0 || has_error() {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{post_count()}" }
|
||||
" 篇文章"
|
||||
}
|
||||
} else {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
"加载中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
for post in posts.iter() {
|
||||
TagPostEntry { post: post.clone() }
|
||||
if has_error() {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败"
|
||||
}
|
||||
} else if posts_data().is_some() {
|
||||
for post in posts_data().unwrap().into_iter() {
|
||||
TagPostEntry { post }
|
||||
}
|
||||
} else {
|
||||
div { class: "space-y-6 py-4 animate-pulse",
|
||||
for _ in 0..3 {
|
||||
div { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]",
|
||||
div { class: "h-7 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded mb-3" }
|
||||
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded mb-2" }
|
||||
div { class: "h-4 w-2/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Footer {}
|
||||
@ -129,8 +173,11 @@ pub fn TagDetail(tag: String) -> Element {
|
||||
|
||||
#[component]
|
||||
fn TagPostEntry(post: Post) -> Element {
|
||||
let tag_items = post.tags.to_vec();
|
||||
let post_slug = post.slug;
|
||||
let post_slug = post.slug.clone();
|
||||
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: "relative mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] hover:-translate-y-0.5 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-250",
|
||||
@ -145,24 +192,24 @@ fn TagPostEntry(post: Post) -> Element {
|
||||
"{post.title}"
|
||||
}
|
||||
div { class: "mt-2 text-sm text-gray-500 dark:text-[#9b9c9d] leading-relaxed line-clamp-2",
|
||||
"{post.summary}"
|
||||
"{post.summary.as_deref().unwrap_or(\"\")}"
|
||||
}
|
||||
div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
|
||||
span { "{post.date}" }
|
||||
span { "·" }
|
||||
for (i, tag_name) in tag_items.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
span { "," }
|
||||
}
|
||||
span {
|
||||
a {
|
||||
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
|
||||
href: "/tags/{tag_name}",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
dioxus::router::navigator().push(format!("/tags/{}", tag_name).as_str());
|
||||
},
|
||||
"{tag_name}"
|
||||
span { "{date_str}" }
|
||||
if !post.tags.is_empty() {
|
||||
span { "·" }
|
||||
for tag in post.tags.clone().into_iter() {
|
||||
span {
|
||||
a {
|
||||
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
|
||||
href: "/tags/{tag}",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
evt.stop_propagation();
|
||||
dioxus::router::navigator().push(format!("/tags/{}", tag).as_str());
|
||||
},
|
||||
"{tag}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,11 @@ use std::sync::Arc;
|
||||
|
||||
use crate::components::admin_layout::AdminLayout;
|
||||
use crate::context::UserContext;
|
||||
use crate::pages::admin::{Admin, Write};
|
||||
use crate::pages::admin::{Admin, Posts, Write};
|
||||
use crate::pages::archives::Archives;
|
||||
use crate::pages::home::Home;
|
||||
use crate::pages::login::Login;
|
||||
use crate::pages::post_detail::PostDetail;
|
||||
use crate::pages::register::Register;
|
||||
use crate::pages::tags::{TagDetail, Tags};
|
||||
use crate::theme::{use_theme_provider, Theme, ThemePreload};
|
||||
@ -27,6 +28,8 @@ pub enum Route {
|
||||
Admin {},
|
||||
#[route("/write")]
|
||||
Write {},
|
||||
#[route("/posts")]
|
||||
Posts {},
|
||||
#[end_layout]
|
||||
#[end_nest]
|
||||
|
||||
@ -36,6 +39,8 @@ pub enum Route {
|
||||
Tags {},
|
||||
#[route("/tags/:tag")]
|
||||
TagDetail { tag: String },
|
||||
#[route("/post/:slug")]
|
||||
PostDetail { slug: String },
|
||||
#[route("/search")]
|
||||
Search {},
|
||||
#[route("/about")]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user