1use 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 ¬e.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}