重构 admin 模块,新增共享组件和文章撰写页面
- 将 monolithic admin.rs 拆分为 admin/ 目录模块(dashboard、write) - 新增 components 模块:Header、Footer、AdminLayout - 新增 /admin/write 文章撰写页面,支持 Markdown 实时预览 - 添加 pulldown-cmark 依赖用于 Markdown 渲染 - .env 移出版本控制,新增 .env.example 模板 - Home、Archives、Tags、Login、Register 等页面适配新组件 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a73e4d931
commit
8b9c6aa83d
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,3 +6,4 @@
|
||||
/package-lock.json
|
||||
others/
|
||||
public/style.css
|
||||
.env
|
||||
|
||||
35
Cargo.lock
generated
35
Cargo.lock
generated
@ -1572,6 +1572,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@ -2708,6 +2717,25 @@ dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@ -3880,6 +3908,12 @@ version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@ -4595,6 +4629,7 @@ dependencies = [
|
||||
"dioxus",
|
||||
"dotenvy",
|
||||
"getrandom 0.2.17",
|
||||
"pulldown-cmark",
|
||||
"rand 0.8.6",
|
||||
"regex",
|
||||
"serde",
|
||||
|
||||
@ -13,6 +13,7 @@ argon2 = "0.5"
|
||||
uuid = { version = "1", features = ["v4", "js"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
regex = "1.12"
|
||||
pulldown-cmark = "0.13"
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
rand = { version = "0.8", features = ["getrandom"] }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
92
src/components/admin_layout.rs
Normal file
92
src/components/admin_layout.rs
Normal file
@ -0,0 +1,92 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::api::auth::{get_current_user, logout};
|
||||
use crate::components::header::{Header, NavItemConfig};
|
||||
use crate::components::footer::Footer;
|
||||
use crate::router::Route;
|
||||
|
||||
#[component]
|
||||
pub fn AdminLayout(children: Element) -> Element {
|
||||
let user_resource =
|
||||
use_resource(|| async move { get_current_user().await.ok().and_then(|r| r.user) });
|
||||
|
||||
let navigator = dioxus::router::navigator();
|
||||
let route = use_route::<Route>();
|
||||
|
||||
let admin_nav_items = vec![
|
||||
NavItemConfig {
|
||||
href: "/admin",
|
||||
label: "仪表盘",
|
||||
is_active: matches!(route, Route::AdminPage {}),
|
||||
},
|
||||
NavItemConfig {
|
||||
href: "/admin/write",
|
||||
label: "写文章",
|
||||
is_active: matches!(route, Route::WritePage {}),
|
||||
},
|
||||
NavItemConfig {
|
||||
href: "/",
|
||||
label: "前台",
|
||||
is_active: false,
|
||||
},
|
||||
];
|
||||
|
||||
let nav = navigator;
|
||||
let logout_button = rsx! {
|
||||
button {
|
||||
class: "text-sm text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors",
|
||||
onclick: move |_| {
|
||||
let nav = nav;
|
||||
spawn(async move {
|
||||
let _ = logout().await;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let cookie = "session=; path=/; max-age=0";
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
let _ = document.dyn_into::<web_sys::HtmlDocument>()
|
||||
.map(|d| d.set_cookie(cookie));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = nav.push("/login");
|
||||
});
|
||||
},
|
||||
"登出"
|
||||
}
|
||||
};
|
||||
|
||||
let user_data = user_resource.read().clone();
|
||||
|
||||
let should_redirect = matches!(user_data.as_ref(), Some(None));
|
||||
|
||||
use_effect(move || {
|
||||
if should_redirect {
|
||||
navigator.push("/login");
|
||||
}
|
||||
});
|
||||
|
||||
match user_data.as_ref() {
|
||||
Some(Some(_user)) => {
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]",
|
||||
Header { nav_items: admin_nav_items, right_content: logout_button }
|
||||
main { class: "flex-1 w-full max-w-5xl mx-auto px-6 py-8",
|
||||
{children}
|
||||
}
|
||||
Footer {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
|
||||
p { class: "text-gray-600 dark:text-[#9b9c9d]", "加载中..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/components/footer.rs
Normal file
88
src/components/footer.rs
Normal file
@ -0,0 +1,88 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Footer() -> Element {
|
||||
let mut visible = use_signal(|| false);
|
||||
|
||||
use_effect(move || {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let threshold = w.inner_height().ok()
|
||||
.and_then(|h| h.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let scroll_y = w.scroll_y().unwrap_or(0.0);
|
||||
let new_visible = scroll_y > threshold;
|
||||
visible.set(new_visible);
|
||||
}
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
let _ = window.add_event_listener_with_callback("scroll", wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()));
|
||||
|
||||
let threshold = window.inner_height().ok()
|
||||
.and_then(|h| h.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let scroll_y = window.scroll_y().unwrap_or(0.0);
|
||||
visible.set(scroll_y > threshold);
|
||||
|
||||
closure.forget();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let link_class = use_memo(move || {
|
||||
let base = "p-2 rounded-full cursor-pointer hover:opacity-80 transition-all duration-300 text-gray-600 dark:text-gray-300";
|
||||
if visible() {
|
||||
format!("{} opacity-100 translate-y-0", base)
|
||||
} else {
|
||||
format!("{} opacity-0 translate-y-2 pointer-events-none", base)
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
footer { class: "w-full border-t border-gray-200 dark:border-[#333] mt-auto",
|
||||
div { class: "max-w-3xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-gray-400 dark:text-[#9b9c9d]",
|
||||
span { "© 2026 Yggdrasil Blog" }
|
||||
a {
|
||||
class: "{link_class}",
|
||||
href: "#top",
|
||||
aria_label: "go to top",
|
||||
title: "Go to Top (Alt + G)",
|
||||
accesskey: "g",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
scroll_to_top();
|
||||
},
|
||||
svg {
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
height: "24px",
|
||||
view_box: "0 -960 960 960",
|
||||
width: "24px",
|
||||
fill: "currentColor",
|
||||
path {
|
||||
d: "m296-224-56-56 240-240 240 240-56 56-184-183-184 183Zm0-240-56-56 240-240 240 240-56 56-184-183-184 183Z",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_to_top() {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
let mut options = web_sys::ScrollToOptions::new();
|
||||
options.top(0.0);
|
||||
options.behavior(web_sys::ScrollBehavior::Smooth);
|
||||
let _ = window.scroll_to_with_scroll_to_options(&options);
|
||||
|
||||
if let Ok(history) = window.history() {
|
||||
let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/components/header.rs
Normal file
51
src/components/header.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct NavItemConfig {
|
||||
pub href: &'static str,
|
||||
pub label: &'static str,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Header(nav_items: Vec<NavItemConfig>, right_content: Element) -> Element {
|
||||
rsx! {
|
||||
header { class: "sticky top-0 z-40 w-full border-b border-gray-200 dark:border-[#333] bg-white/80 dark:bg-[#1d1e20]/80 backdrop-blur-sm",
|
||||
nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between",
|
||||
a {
|
||||
class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
||||
href: "/",
|
||||
"Yggdrasil"
|
||||
}
|
||||
div { class: "flex items-center gap-2",
|
||||
ul { class: "hidden md:flex items-center gap-1",
|
||||
for item in nav_items.iter().cloned() {
|
||||
NavItem {
|
||||
href: item.href,
|
||||
label: item.label,
|
||||
is_active: item.is_active,
|
||||
}
|
||||
}
|
||||
}
|
||||
{right_content}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NavItem(href: &'static str, label: &'static str, is_active: bool) -> Element {
|
||||
let base_class = "px-3 py-1 text-base rounded-lg transition-colors";
|
||||
let class_str = if is_active {
|
||||
format!("{} font-medium text-gray-900 dark:text-[#dadadb] underline underline-offset-[0.3rem] decoration-2 decoration-gray-900 dark:decoration-[#dadadb]", base_class)
|
||||
} else {
|
||||
format!("{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]", base_class)
|
||||
};
|
||||
|
||||
rsx! {
|
||||
li {
|
||||
a { class: "{class_str}", href: "{href}", "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/components/mod.rs
Normal file
3
src/components/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod header;
|
||||
pub mod footer;
|
||||
pub mod admin_layout;
|
||||
@ -1,5 +1,6 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod components;
|
||||
mod db;
|
||||
mod models;
|
||||
mod pages;
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::api::auth::{get_current_user, logout};
|
||||
|
||||
#[component]
|
||||
pub fn AdminPage() -> Element {
|
||||
let user_resource =
|
||||
use_resource(|| async move { get_current_user().await.ok().and_then(|r| r.user) });
|
||||
|
||||
let navigator = dioxus::router::navigator();
|
||||
|
||||
let user_data = user_resource.read().clone();
|
||||
|
||||
match user_data.as_ref() {
|
||||
Some(Some(user)) => {
|
||||
let username = user.username.clone();
|
||||
rsx! {
|
||||
div { class: "min-h-screen bg-gray-50 dark:bg-gray-900",
|
||||
header { class: "bg-white dark:bg-gray-800 shadow",
|
||||
div { class: "max-w-7xl mx-auto px-4 py-4 flex justify-between items-center",
|
||||
h1 { class: "text-xl font-bold text-gray-900 dark:text-white",
|
||||
"后台管理"
|
||||
}
|
||||
div { class: "flex items-center gap-4",
|
||||
span { class: "text-gray-600 dark:text-gray-300",
|
||||
"欢迎, {username}"
|
||||
}
|
||||
button {
|
||||
class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors",
|
||||
onclick: move |_| {
|
||||
let nav = navigator;
|
||||
spawn(async move {
|
||||
let _ = logout().await;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let cookie = "session=; path=/; max-age=0";
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
let _ = document.dyn_into::<web_sys::HtmlDocument>()
|
||||
.map(|d| d.set_cookie(cookie));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = nav.push("/login");
|
||||
});
|
||||
},
|
||||
"登出"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
main { class: "max-w-7xl mx-auto px-4 py-8",
|
||||
p { class: "text-gray-600 dark:text-gray-300",
|
||||
"后台管理界面开发中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
use_effect(move || {
|
||||
navigator.push("/login");
|
||||
});
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
p { class: "text-gray-600 dark:text-gray-300", "正在跳转..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
p { class: "text-gray-600 dark:text-gray-300", "加载中..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/pages/admin/dashboard.rs
Normal file
77
src/pages/admin/dashboard.rs
Normal file
@ -0,0 +1,77 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::admin_layout::AdminLayout;
|
||||
use crate::pages::home::{Post, POSTS};
|
||||
|
||||
#[component]
|
||||
pub fn AdminPage() -> Element {
|
||||
rsx! {
|
||||
AdminLayout {
|
||||
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: "已发布" }
|
||||
}
|
||||
|
||||
// 快捷操作
|
||||
div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
|
||||
a {
|
||||
class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity",
|
||||
href: "/admin/write",
|
||||
"写文章"
|
||||
}
|
||||
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",
|
||||
onclick: move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::window().map(|w| w.alert_with_message("开发中").ok());
|
||||
},
|
||||
"管理文章"
|
||||
}
|
||||
}
|
||||
|
||||
// 最近文章
|
||||
div { class: "mb-8",
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn StatCard(value: String, label: String) -> Element {
|
||||
rsx! {
|
||||
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center",
|
||||
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
"{value}"
|
||||
}
|
||||
div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2",
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn RecentPostItem(post: Post) -> Element {
|
||||
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}"
|
||||
}
|
||||
span { class: "text-sm text-gray-400 dark:text-[#9b9c9d]",
|
||||
"{post.date}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/pages/admin/mod.rs
Normal file
5
src/pages/admin/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod dashboard;
|
||||
pub mod write;
|
||||
|
||||
pub use dashboard::AdminPage;
|
||||
pub use write::WritePage;
|
||||
71
src/pages/admin/write.rs
Normal file
71
src/pages/admin/write.rs
Normal file
@ -0,0 +1,71 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::admin_layout::AdminLayout;
|
||||
|
||||
fn markdown_to_html(input: &str) -> String {
|
||||
let parser = pulldown_cmark::Parser::new(input);
|
||||
let mut html = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
html
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn WritePage() -> Element {
|
||||
let mut title = use_signal(|| "".to_string());
|
||||
let mut content = use_signal(|| "".to_string());
|
||||
let preview_html = use_memo(move || {
|
||||
markdown_to_html(&content())
|
||||
});
|
||||
|
||||
rsx! {
|
||||
AdminLayout {
|
||||
div { class: "space-y-4",
|
||||
// 标题输入
|
||||
input {
|
||||
class: "w-full text-2xl font-bold bg-transparent border-b border-gray-200 dark:border-[#333] py-2 mb-4 text-gray-900 dark:text-[#dadadb] placeholder-gray-400 dark:placeholder-[#9b9c9d] focus:outline-none",
|
||||
placeholder: "文章标题...",
|
||||
value: "{title}",
|
||||
oninput: move |evt| title.set(evt.value()),
|
||||
}
|
||||
|
||||
// 两栏布局
|
||||
div { class: "grid grid-cols-1 md:grid-cols-2 gap-6",
|
||||
// 编辑区
|
||||
div { class: "space-y-2",
|
||||
label { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
"Markdown"
|
||||
}
|
||||
textarea {
|
||||
class: "w-full h-[500px] bg-gray-50 dark:bg-[#2e2e33] rounded-lg p-4 font-mono text-sm text-gray-800 dark:text-[#dadadb] placeholder-gray-400 dark:placeholder-[#9b9c9d] border border-gray-200 dark:border-[#333] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600 resize-none",
|
||||
placeholder: "在此输入 Markdown...",
|
||||
value: "{content}",
|
||||
oninput: move |evt| content.set(evt.value()),
|
||||
}
|
||||
}
|
||||
|
||||
// 预览区
|
||||
div { class: "space-y-2",
|
||||
label { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
"预览"
|
||||
}
|
||||
div {
|
||||
class: "w-full h-[500px] overflow-y-auto bg-white dark:bg-[#2e2e33] rounded-lg p-4 border border-gray-200 dark:border-[#333] prose dark:prose-invert max-w-none",
|
||||
dangerous_inner_html: "{preview_html}",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存按钮
|
||||
button {
|
||||
class: "mt-4 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 t = title();
|
||||
let c = content();
|
||||
println!("保存文章: title={}, content_len={}", t, c.len());
|
||||
},
|
||||
"保存草稿"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::pages::home::{Footer, Header};
|
||||
use crate::components::header::{Header, NavItemConfig};
|
||||
use crate::components::footer::Footer;
|
||||
use crate::router::Route;
|
||||
use crate::theme::ThemeToggle;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Post {
|
||||
@ -112,11 +115,20 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
||||
|
||||
#[component]
|
||||
pub fn ArchivesPage() -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let nav_items = vec![
|
||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
|
||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
|
||||
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
|
||||
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
|
||||
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
|
||||
];
|
||||
|
||||
let grouped = group_posts(POSTS);
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
||||
Header {}
|
||||
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
|
||||
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::header::{Header, NavItemConfig};
|
||||
use crate::components::footer::Footer;
|
||||
use crate::router::Route;
|
||||
use crate::theme::ThemeToggle;
|
||||
|
||||
@ -59,9 +61,18 @@ pub const POSTS: &[Post] = &[
|
||||
|
||||
#[component]
|
||||
pub fn HomePage() -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let nav_items = vec![
|
||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
|
||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
|
||||
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
|
||||
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
|
||||
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
||||
Header {}
|
||||
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() {
|
||||
@ -74,59 +85,6 @@ pub fn HomePage() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Header() -> Element {
|
||||
let route = use_route::<Route>();
|
||||
|
||||
rsx! {
|
||||
header { class: "sticky top-0 z-40 w-full border-b border-gray-200 dark:border-[#333] bg-white/80 dark:bg-[#1d1e20]/80 backdrop-blur-sm",
|
||||
nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between",
|
||||
a {
|
||||
class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
||||
href: "/",
|
||||
"Yggdrasil"
|
||||
}
|
||||
div { class: "flex items-center gap-2",
|
||||
ul { class: "hidden md:flex items-center gap-1",
|
||||
NavItem { href: "/", label: "首页", route: route.clone() }
|
||||
NavItem { href: "/archives", label: "归档", route: route.clone() }
|
||||
NavItem { href: "/tags", label: "标签", route: route.clone() }
|
||||
NavItem { href: "/search", label: "搜索", route: route.clone() }
|
||||
NavItem { href: "/about", label: "关于", route: route.clone() }
|
||||
}
|
||||
ThemeToggle {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn NavItem(href: &'static str, label: &'static str, route: Route) -> Element {
|
||||
let is_active = match (href, route) {
|
||||
("/", Route::HomePage {}) => true,
|
||||
("/archives", Route::ArchivesPage {}) => true,
|
||||
("/tags", Route::TagsPage {}) => true,
|
||||
("/tags", Route::TagDetailPage { .. }) => true,
|
||||
("/search", Route::SearchPage {}) => true,
|
||||
("/about", Route::AboutPage {}) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let base_class = "px-3 py-1 text-base rounded-lg transition-colors";
|
||||
let class_str = if is_active {
|
||||
format!("{} font-medium text-gray-900 dark:text-[#dadadb] underline underline-offset-[0.3rem] decoration-2 decoration-gray-900 dark:decoration-[#dadadb]", base_class)
|
||||
} else {
|
||||
format!("{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]", base_class)
|
||||
};
|
||||
|
||||
rsx! {
|
||||
li {
|
||||
a { class: "{class_str}", href: "{href}", "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn HomeInfo() -> Element {
|
||||
rsx! {
|
||||
@ -143,7 +101,7 @@ fn HomeInfo() -> Element {
|
||||
|
||||
#[component]
|
||||
fn PostEntry(post: Post) -> Element {
|
||||
let tag_items = post.tags.iter().map(|t| *t).collect::<Vec<_>>();
|
||||
let tag_items = post.tags.to_vec();
|
||||
|
||||
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",
|
||||
@ -182,90 +140,3 @@ fn Pagination() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Footer() -> Element {
|
||||
let mut visible = use_signal(|| false);
|
||||
|
||||
use_effect(move || {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let threshold = w.inner_height().ok()
|
||||
.and_then(|h| h.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let scroll_y = w.scroll_y().unwrap_or(0.0);
|
||||
let new_visible = scroll_y > threshold;
|
||||
visible.set(new_visible);
|
||||
}
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
let _ = window.add_event_listener_with_callback("scroll", wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()));
|
||||
|
||||
let threshold = window.inner_height().ok()
|
||||
.and_then(|h| h.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let scroll_y = window.scroll_y().unwrap_or(0.0);
|
||||
visible.set(scroll_y > threshold);
|
||||
|
||||
closure.forget();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let link_class = use_memo(move || {
|
||||
let base = "p-2 rounded-full cursor-pointer hover:opacity-80 transition-all duration-300 text-gray-600 dark:text-gray-300";
|
||||
if visible() {
|
||||
format!("{} opacity-100 translate-y-0", base)
|
||||
} else {
|
||||
format!("{} opacity-0 translate-y-2 pointer-events-none", base)
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
footer { class: "w-full border-t border-gray-200 dark:border-[#333] mt-auto",
|
||||
div { class: "max-w-3xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-gray-400 dark:text-[#9b9c9d]",
|
||||
span { "© 2026 Yggdrasil Blog" }
|
||||
a {
|
||||
class: "{link_class}",
|
||||
href: "#top",
|
||||
aria_label: "go to top",
|
||||
title: "Go to Top (Alt + G)",
|
||||
accesskey: "g",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
scroll_to_top();
|
||||
},
|
||||
svg {
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
height: "24px",
|
||||
view_box: "0 -960 960 960",
|
||||
width: "24px",
|
||||
fill: "currentColor",
|
||||
path {
|
||||
d: "m296-224-56-56 240-240 240 240-56 56-184-183-184 183Zm0-240-56-56 240-240 240 240-56 56-184-183-184 183Z",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_to_top() {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
let mut options = web_sys::ScrollToOptions::new();
|
||||
options.top(0.0);
|
||||
options.behavior(web_sys::ScrollBehavior::Smooth);
|
||||
let _ = window.scroll_to_with_scroll_to_options(&options);
|
||||
|
||||
if let Ok(history) = window.history() {
|
||||
let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,13 +24,12 @@ pub fn LoginPage() -> Element {
|
||||
token: Some(_token),
|
||||
..
|
||||
}) => {
|
||||
// 设置 cookie (client-side, not HttpOnly but works for now)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let cookie = format!(
|
||||
"session={}; path=/; max-age={}; SameSite=Lax",
|
||||
_token,
|
||||
30 * 24 * 60 * 60 // 30 days
|
||||
30 * 24 * 60 * 60
|
||||
);
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
@ -40,7 +39,6 @@ pub fn LoginPage() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 跳转到 admin 页面
|
||||
let _ = dioxus::router::navigator().push("/admin");
|
||||
}
|
||||
Ok(AuthResponse {
|
||||
@ -65,25 +63,25 @@ pub fn LoginPage() -> Element {
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-lg",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-white mb-6",
|
||||
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-[#2e2e33] rounded-2xl border border-gray-200 dark:border-[#333]",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-[#dadadb] mb-6",
|
||||
"登录"
|
||||
}
|
||||
|
||||
if let Some(err) = error() {
|
||||
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded-lg text-center",
|
||||
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-center",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "space-y-4",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] mb-1",
|
||||
"用户名"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
|
||||
r#type: "text",
|
||||
placeholder: "用户名",
|
||||
value: username(),
|
||||
@ -91,11 +89,11 @@ pub fn LoginPage() -> Element {
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] mb-1",
|
||||
"密码"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
|
||||
r#type: "password",
|
||||
placeholder: "密码",
|
||||
value: password(),
|
||||
@ -103,12 +101,12 @@ pub fn LoginPage() -> Element {
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
|
||||
class: "w-full py-2 px-4 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 font-medium rounded-full hover:opacity-80 transition-opacity",
|
||||
onclick: on_submit,
|
||||
"登录"
|
||||
}
|
||||
a {
|
||||
class: "block w-full py-2 px-4 text-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium rounded-lg transition-colors",
|
||||
class: "block w-full py-2 px-4 text-center text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] font-medium rounded-lg transition-colors",
|
||||
href: "/register",
|
||||
"还没有账号?去注册"
|
||||
}
|
||||
|
||||
@ -44,37 +44,37 @@ pub fn RegisterPage() -> Element {
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-lg",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-white mb-2",
|
||||
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-[#2e2e33] rounded-2xl border border-gray-200 dark:border-[#333]",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-[#dadadb] mb-2",
|
||||
"注册"
|
||||
}
|
||||
p { class: "text-sm text-center text-gray-500 dark:text-gray-400 mb-6",
|
||||
p { class: "text-sm text-center text-gray-500 dark:text-[#9b9c9d] mb-6",
|
||||
"首个注册账号将自动成为管理员"
|
||||
}
|
||||
|
||||
if success() {
|
||||
div { class: "mb-4 p-3 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded-lg text-center",
|
||||
div { class: "mb-4 p-3 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg text-center",
|
||||
"注册成功!"
|
||||
a { class: "block mt-2 text-blue-600 dark:text-blue-400 hover:underline", href: "/login",
|
||||
a { class: "block mt-2 text-gray-700 dark:text-[#dadadb] hover:underline", href: "/login",
|
||||
"去登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = error() {
|
||||
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded-lg text-center",
|
||||
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-center",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "space-y-4",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] mb-1",
|
||||
"用户名"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
|
||||
r#type: "text",
|
||||
placeholder: "3-50 位字符",
|
||||
value: username(),
|
||||
@ -82,11 +82,11 @@ pub fn RegisterPage() -> Element {
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] mb-1",
|
||||
"邮箱"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
|
||||
r#type: "email",
|
||||
placeholder: "your@email.com",
|
||||
value: email(),
|
||||
@ -94,11 +94,11 @@ pub fn RegisterPage() -> Element {
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] mb-1",
|
||||
"密码"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
|
||||
r#type: "password",
|
||||
placeholder: "至少 8 位",
|
||||
value: password(),
|
||||
@ -106,11 +106,11 @@ pub fn RegisterPage() -> Element {
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] mb-1",
|
||||
"确认密码"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
|
||||
r#type: "password",
|
||||
placeholder: "再次输入密码",
|
||||
value: confirm_password(),
|
||||
@ -118,14 +118,14 @@ pub fn RegisterPage() -> Element {
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
|
||||
class: "w-full py-2 px-4 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 font-medium rounded-full hover:opacity-80 transition-opacity",
|
||||
onclick: on_submit,
|
||||
"注册"
|
||||
}
|
||||
}
|
||||
p { class: "mt-4 text-center text-sm text-gray-500 dark:text-gray-400",
|
||||
p { class: "mt-4 text-center text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
"已有账号?"
|
||||
a { class: "text-blue-600 dark:text-blue-400 hover:underline", href: "/login",
|
||||
a { class: "text-gray-700 dark:text-[#dadadb] hover:underline", href: "/login",
|
||||
"去登录"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::pages::home::{Footer, Header, Post, POSTS};
|
||||
use crate::components::header::{Header, NavItemConfig};
|
||||
use crate::components::footer::Footer;
|
||||
use crate::pages::home::{Post, POSTS};
|
||||
use crate::router::Route;
|
||||
use crate::theme::ThemeToggle;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct TagInfo {
|
||||
@ -30,19 +34,28 @@ fn collect_tags() -> Vec<TagInfo> {
|
||||
fn posts_for_tag(tag: &str) -> Vec<Post> {
|
||||
POSTS
|
||||
.iter()
|
||||
.filter(|p| p.tags.iter().any(|t| *t == tag))
|
||||
.filter(|p| p.tags.contains(&tag))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TagsPage() -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let nav_items = vec![
|
||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
|
||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
|
||||
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
|
||||
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
|
||||
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
|
||||
];
|
||||
|
||||
let tags = collect_tags();
|
||||
let total_posts = POSTS.len();
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
||||
Header {}
|
||||
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
|
||||
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
@ -76,11 +89,20 @@ pub fn TagsPage() -> Element {
|
||||
|
||||
#[component]
|
||||
pub fn TagDetailPage(tag: String) -> Element {
|
||||
let route = use_route::<Route>();
|
||||
let nav_items = vec![
|
||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
|
||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
|
||||
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
|
||||
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
|
||||
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
|
||||
];
|
||||
|
||||
let posts = posts_for_tag(&tag);
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
||||
Header {}
|
||||
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
|
||||
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
@ -103,7 +125,7 @@ pub fn TagDetailPage(tag: String) -> Element {
|
||||
|
||||
#[component]
|
||||
fn TagPostEntry(post: Post) -> Element {
|
||||
let tag_items = post.tags.iter().map(|t| *t).collect::<Vec<_>>();
|
||||
let tag_items = post.tags.to_vec();
|
||||
|
||||
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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::pages::admin::AdminPage;
|
||||
use crate::pages::admin::{AdminPage, WritePage};
|
||||
use crate::pages::archives::ArchivesPage;
|
||||
use crate::pages::home::HomePage;
|
||||
use crate::pages::login::LoginPage;
|
||||
@ -18,6 +18,8 @@ pub enum Route {
|
||||
RegisterPage {},
|
||||
#[route("/admin")]
|
||||
AdminPage {},
|
||||
#[route("/admin/write")]
|
||||
WritePage {},
|
||||
#[route("/archives")]
|
||||
ArchivesPage {},
|
||||
#[route("/tags")]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user