Skip to main content

yggdrasil/pages/
notes.rs

1//! 数字花园的笔记入口:随记时间流、主题笔记和有序笔记本。
2use crate::api::notes::{get_note, list_notebooks, list_notes};
3use crate::components::empty_state::EmptyState;
4use crate::components::post::{post_content::PostContent, post_toc::PostToc};
5use crate::models::note::*;
6use crate::router::Route;
7use dioxus::prelude::*;
8
9#[component]
10pub fn Notes() -> Element {
11    rsx! {
12        document::Title { "笔记 · Yggdrasil" }
13        div { class: "notes-page",
14            header { class: "notes-hero",
15                div {
16                    p { class: "notes-eyebrow", "FIELD NOTES / 生长中的想法" }
17                    h1 { "随手记下," span { "慢慢成形。" } }
18                    p { class: "notes-intro", "一些此刻的发现,一些反复琢磨的答案。" }
19                }
20                div { class: "notes-hero-mark", aria_hidden: "true",
21                    svg { view_box: "0 0 120 140", fill: "none", stroke: "currentColor", stroke_width: "1.3",
22                        path { d: "M58 130V38M58 100C26 104 13 85 14 63C40 61 58 74 58 100ZM59 77C88 77 103 56 101 32C74 35 58 48 59 77ZM58 48C43 37 42 20 52 8C66 20 72 36 58 48Z" }
23                        path { d: "M35 131H82M58 100L29 78M59 77L88 46", opacity: "0.5" }
24                    }
25                    span { "保持好奇 / KEEP GROWING" }
26                }
27            }
28            NoteGarden {}
29        }
30    }
31}
32
33#[component]
34fn NoteGarden() -> Element {
35    let entry_id = use_hook(crate::bridges::navigation::entry_id);
36    let initial = use_hook(|| {
37        crate::bridges::navigation::read_state::<NoteFilter>("notes").unwrap_or_default()
38    });
39    let mut filter = use_signal(|| initial.clone());
40    let mut query = use_signal(|| initial.query.clone());
41    let mut books_only = use_signal(|| false);
42    use_effect(move || crate::bridges::navigation::write_state(&entry_id, "notes", &filter()));
43    let books = use_server_future(list_notebooks)?;
44    rsx! {
45        form { class: "notes-search", onsubmit: move |ev| {
46            ev.prevent_default();
47            filter.with_mut(|f| { f.query=query(); f.page=1; });
48            books_only.set(false);
49        },
50            span { aria_hidden: "true", "⌕" }
51            input { r#type: "search", aria_label: "搜索笔记", placeholder: "寻找一个想法、关键词或标签…", value: "{query}", oninput: move |ev| query.set(ev.value()) }
52            button { r#type: "submit", "搜索" }
53        }
54        div { class: "notes-tabs", aria_label: "笔记类型",
55            for (kind,label) in [(None,"全部"),(Some(NoteKind::Moment),"随记"),(Some(NoteKind::Topic),"主题笔记")] {
56                button { r#type: "button", aria_pressed: !books_only() && filter().kind==kind,
57                    class: if !books_only() && filter().kind==kind { "active" } else { "" },
58                    onclick: move |_| { books_only.set(false); filter.with_mut(|f| {f.kind=kind; f.page=1;}); }, "{label}"
59                }
60            }
61            button { r#type: "button", aria_pressed: books_only(), class: if books_only() { "active" } else { "" }, onclick: move |_| books_only.set(true), "笔记本" }
62        }
63        if let Some(Ok(items)) = books.read().as_ref() {
64            if !items.is_empty() && (books_only() || (filter().kind.is_none() && filter().query.is_empty() && filter().tag.is_empty())) {
65                section { class: "notebook-section", aria_label: "笔记本",
66                    div { class: "notes-section-heading", h2 { "沿着一个主题" } span { "把零散的发现,放在一起。" } }
67                    div { class: "notebook-grid",
68                        for book in items.iter().take(if books_only() {usize::MAX} else {4}) {
69                            NotebookCard { key: "{book.id}", book: book.clone() }
70                        }
71                    }
72                }
73            } else if books_only() {
74                EmptyState { title: "笔记本正在生长", description: "整理好的主题会出现在这里。" }
75            }
76        }
77        if !books_only() {
78            if !filter().tag.is_empty() {
79                button { class: "note-tag", onclick: move |_| filter.with_mut(|f| {f.tag.clear();f.page=1;}), "#{filter().tag} ×" }
80            }
81            SuspenseBoundary { fallback: |_| rsx! { NotesSkeleton {} },
82                NoteFeed { filter, on_tag: move |tag| filter.with_mut(|f| {f.tag=tag;f.page=1;}) }
83            }
84        }
85        p { class: "notes-colophon", "想法不必完整,也值得被记下。" }
86    }
87}
88
89#[component]
90fn NotebookCard(book: Notebook) -> Element {
91    rsx! {
92        Link { class: "notebook-card", to: Route::NotebookDetail { id: book.id },
93            span { class: "notebook-spine", aria_hidden: "true" }
94            div { class: "notebook-top", span { "NOTEBOOK" } span { "↗" } }
95            h3 { "{book.title}" }
96            p { "{book.description}" }
97            footer { span { "{book.note_count} 篇笔记" } span { "持续整理" } }
98        }
99    }
100}
101
102#[component]
103fn NoteFeed(mut filter: Signal<NoteFilter>, on_tag: EventHandler<String>) -> Element {
104    let response = use_server_future(move || list_notes(filter()))?;
105    let data = response.read();
106    rsx! {
107        section { class: "note-feed", aria_label: "笔记列表",
108            div { class: "notes-section-heading", h2 { if !filter().query.is_empty() { "搜索结果" } else if filter().notebook_id.is_some() { "笔记目录" } else { "最近记录" } }
109                if let Some(Ok(page)) = data.as_ref() { span { "{page.total} 条记录" } }
110            }
111            match data.as_ref() {
112                Some(Ok(page)) if !page.notes.is_empty() => rsx! {
113                    for note in &page.notes { NoteEntry { key: "{note.id}-{note.version}", note: note.clone(), on_tag } }
114                    div { class: "notes-pagination",
115                        button { disabled: filter().page<=1, onclick: move |_| filter.with_mut(|f| f.page=(f.page-1).max(1)), "← 上一页" }
116                        span { "第 {filter().page.max(1)} 页" }
117                        button { disabled: i64::from(filter().page.max(1))*20>=page.total, onclick: move |_| filter.with_mut(|f| f.page=f.page.max(1)+1), "下一页 →" }
118                    }
119                },
120                Some(Ok(_)) => rsx! { EmptyState { title: "这里还没有笔记", description: "换个关键词看看,或等下一次灵感落下。" } },
121                Some(Err(_)) => rsx! { p { role: "alert", "笔记暂时加载失败,请刷新重试。" } },
122                None => rsx! { NotesSkeleton {} },
123            }
124        }
125    }
126}
127
128#[component]
129fn NoteEntry(note: Note, on_tag: EventHandler<String>) -> Element {
130    let title = note.display_title();
131    rsx! {
132        article { class: "note-entry",
133            div { class: "note-date", time { datetime: note.updated_at.to_rfc3339(), {note.updated_at.format("%m.%d").to_string()} } span { {note.updated_at.format("%Y").to_string()} } }
134            div { class: "note-entry-body",
135                div { class: "note-entry-top", span { class: "note-kind", "{note.kind.label()}" }
136                    Link { class: "note-permalink", to: Route::NoteDetail { slug: note.slug.clone() }, aria_label: "阅读这条笔记", "↗" }
137                }
138                if !note.title.is_empty() { h3 { Link { to: Route::NoteDetail { slug: note.slug.clone() }, "{title}" } } }
139                if note.kind==NoteKind::Moment && !note.content_html.is_empty() {
140                    div { class: "note-moment-content", PostContent { content_html: note.content_html.clone() } }
141                } else {
142                    p { class: "note-summary", "{note.summary}" }
143                    Link { class: "note-read", to: Route::NoteDetail { slug: note.slug.clone() }, "继续阅读 →" }
144                }
145                div { class: "note-tags", for tag in note.tags { button { class: "note-tag", onclick: move |_| on_tag.call(tag.clone()), "#{tag}" } } }
146            }
147        }
148    }
149}
150
151#[component]
152pub fn NotebookDetail(id: i32) -> Element {
153    rsx! { for key in std::iter::once(id) { NotebookReader { key: "{key}", id: key } } }
154}
155
156#[component]
157fn NotebookReader(id: i32) -> Element {
158    let books = use_server_future(list_notebooks)?;
159    let mut filter = use_signal(|| NoteFilter {
160        notebook_id: Some(id),
161        ..Default::default()
162    });
163    let book = books
164        .read()
165        .as_ref()
166        .and_then(|r| r.as_ref().ok())
167        .and_then(|books| books.iter().find(|b| b.id == id))
168        .cloned();
169    let Some(book) = book else {
170        return rsx! { EmptyState {title:"笔记本不存在",description:"这个笔记本尚未公开,或已经被移除。"} };
171    };
172    rsx! {
173        document::Title { "{book.title} · 笔记本" }
174        Link { class: "note-read", to: Route::Notes {}, "← 全部笔记" }
175        header { class: "notes-hero notebook-heading", div {
176            p { class: "notes-eyebrow", "NOTEBOOK / 主题索引" }
177            h1 { "{book.title}" } p { class: "notes-intro", "{book.description}" }
178        } }
179        if !filter().tag.is_empty() {
180            button {class:"note-tag",onclick:move |_|filter.with_mut(|f|{f.tag.clear();f.page=1;}),"#{filter().tag} ×"}
181        }
182        NoteFeed { filter, on_tag: move |tag| filter.with_mut(|f| {f.tag=tag;f.page=1;}) }
183    }
184}
185
186#[component]
187pub fn NoteDetail(slug: String) -> Element {
188    rsx! { for key in std::iter::once(slug) { NoteReader { key: "{key}", slug: key } } }
189}
190
191#[component]
192fn NoteReader(slug: String) -> Element {
193    let note = use_server_future(move || get_note(slug.clone()))?;
194    let data = note.read();
195    match data.as_ref() {
196        Some(Ok(note)) => rsx! { NoteDocument { note: note.clone() } },
197        Some(Err(_)) => {
198            rsx! { EmptyState { title: "这条笔记暂时无法阅读", description: "笔记可能尚未公开、已撤回,或服务暂时不可用。" } }
199        }
200        None => rsx! { NotesSkeleton {} },
201    }
202}
203
204#[component]
205pub fn NoteDocument(note: Note, #[props(default)] preview: bool) -> Element {
206    let title = note.display_title();
207    rsx! {
208        document::Title { "{title} · 笔记" }
209        article { class: "note-document post-single",
210            if !preview { Link { class: "note-read", to: Route::Notes {}, "← 回到笔记" } }
211            header { class: "note-document-header",
212                p { class: "notes-eyebrow", "{note.kind.label()} / FIELD NOTE" }
213                h1 { "{title}" }
214                p { class: "note-document-meta", {format!("更新于 {} · 版本 {}", note.updated_at.format("%Y-%m-%d"), note.version)} }
215                div { class: "note-tags", for tag in &note.tags { span { class: "note-tag", "#{tag}" } } }
216            }
217            if !note.toc_html.is_empty() { PostToc { toc_html: note.toc_html.clone(), title: "本篇目录" } }
218            PostContent { content_html: note.content_html.clone() }
219            footer { class: "notes-colophon", {format!("记录于 {} · 想法仍在生长。", note.created_at.format("%Y-%m-%d"))} }
220        }
221    }
222}
223
224#[component]
225pub fn NotesSkeleton() -> Element {
226    rsx! { div { class: "notes-skeleton", role: "status", aria_label: "正在加载笔记",
227        for i in 0..3 { div { key: "{i}", class: "notes-skeleton-line" } }
228    } }
229}