diff --git a/.env b/.env.example similarity index 100% rename from .env rename to .env.example diff --git a/.gitignore b/.gitignore index 64cdc29..6aeccad 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /package-lock.json others/ public/style.css +.env diff --git a/Cargo.lock b/Cargo.lock index 41aa181..baa0c61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index aee9135..b406dd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs new file mode 100644 index 0000000..2819c91 --- /dev/null +++ b/src/components/admin_layout.rs @@ -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::(); + + 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::() + .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]", "加载中..." } + } + } + } + } +} diff --git a/src/components/footer.rs b/src/components/footer.rs new file mode 100644 index 0000000..9c5f712 --- /dev/null +++ b/src/components/footer.rs @@ -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); + + 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(" ")); + } + } + } +} diff --git a/src/components/header.rs b/src/components/header.rs new file mode 100644 index 0000000..6c5cfc8 --- /dev/null +++ b/src/components/header.rs @@ -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, 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}" } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..fb22d54 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,3 @@ +pub mod header; +pub mod footer; +pub mod admin_layout; diff --git a/src/main.rs b/src/main.rs index e7b5e65..206fa7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod api; mod auth; +mod components; mod db; mod models; mod pages; diff --git a/src/pages/admin.rs b/src/pages/admin.rs deleted file mode 100644 index 9c6714f..0000000 --- a/src/pages/admin.rs +++ /dev/null @@ -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::() - .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", "加载中..." } - } - } - } - } -} diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs new file mode 100644 index 0000000..a9bb685 --- /dev/null +++ b/src/pages/admin/dashboard.rs @@ -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}" + } + } + } +} diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs new file mode 100644 index 0000000..7971da7 --- /dev/null +++ b/src/pages/admin/mod.rs @@ -0,0 +1,5 @@ +pub mod dashboard; +pub mod write; + +pub use dashboard::AdminPage; +pub use write::WritePage; diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs new file mode 100644 index 0000000..1cd96fa --- /dev/null +++ b/src/pages/admin/write.rs @@ -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()); + }, + "保存草稿" + } + } + } + } +} diff --git a/src/pages/archives.rs b/src/pages/archives.rs index 572433c..7440df2 100644 --- a/src/pages/archives.rs +++ b/src/pages/archives.rs @@ -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 { #[component] pub fn ArchivesPage() -> Element { + let route = use_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]", diff --git a/src/pages/home.rs b/src/pages/home.rs index eedbe5c..a0efba0 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -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::(); + 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::(); - - 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::>(); + 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); - - 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(" ")); - } - } - } -} diff --git a/src/pages/login.rs b/src/pages/login.rs index dac5bc8..6b5d1e3 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -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", "还没有账号?去注册" } diff --git a/src/pages/register.rs b/src/pages/register.rs index 3afc8f6..3ec12db 100644 --- a/src/pages/register.rs +++ b/src/pages/register.rs @@ -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", "去登录" } } diff --git a/src/pages/tags.rs b/src/pages/tags.rs index e06296e..4fdb8fc 100644 --- a/src/pages/tags.rs +++ b/src/pages/tags.rs @@ -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 { fn posts_for_tag(tag: &str) -> Vec { 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::(); + 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::(); + 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::>(); + 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", diff --git a/src/router.rs b/src/router.rs index dfea070..fd40c78 100644 --- a/src/router.rs +++ b/src/router.rs @@ -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")]