//! Backlinks panel component for showing incoming links to a note. use dioxus::prelude::*; use crate::client::{ApiClient, BacklinkItem, BacklinksResponse}; /// Panel displaying backlinks (incoming links) to a media item. #[component] pub fn BacklinksPanel( media_id: String, client: ApiClient, on_navigate: EventHandler, ) -> Element { let mut backlinks = use_signal(|| Option::::None); let mut loading = use_signal(|| true); let mut error = use_signal(|| Option::::None); let mut collapsed = use_signal(|| false); let mut reindexing = use_signal(|| false); let mut reindex_message = use_signal(|| Option::<(String, bool)>::None); // (message, is_error) // Fetch backlinks function let fetch_backlinks = { let client = client.clone(); let id = media_id.clone(); move || { let client = client.clone(); let id = id.clone(); spawn(async move { loading.set(true); error.set(None); match client.get_backlinks(&id).await { Ok(resp) => { backlinks.set(Some(resp)); } Err(e) => { error.set(Some(format!("Failed to load backlinks: {e}"))); } } loading.set(false); }); } }; // Fetch backlinks on mount let fetch_on_mount = fetch_backlinks.clone(); use_effect(move || { fetch_on_mount(); }); // Reindex links handler let on_reindex = { let client = client.clone(); let id = media_id.clone(); let fetch_backlinks = fetch_backlinks.clone(); move |evt: MouseEvent| { evt.stop_propagation(); // Don't toggle collapse let client = client.clone(); let id = id.clone(); let fetch_backlinks = fetch_backlinks.clone(); spawn(async move { reindexing.set(true); reindex_message.set(None); match client.reindex_links(&id).await { Ok(resp) => { reindex_message.set(Some(( format!("Reindexed: {} links extracted", resp.links_extracted), false, ))); // Refresh backlinks after reindex fetch_backlinks(); } Err(e) => { reindex_message.set(Some((format!("Reindex failed: {e}"), true))); } } reindexing.set(false); }); } }; let is_loading = *loading.read(); let is_collapsed = *collapsed.read(); let is_reindexing = *reindexing.read(); let backlink_data = backlinks.read(); let count = backlink_data.as_ref().map(|b| b.count).unwrap_or(0); rsx! { div { class: "backlinks-panel", // Header with toggle div { class: "backlinks-header", onclick: move |_| { let current = *collapsed.read(); collapsed.set(!current); }, span { class: "backlinks-toggle", if is_collapsed { "\u{25b6}" } else { "\u{25bc}" } } span { class: "backlinks-title", "Backlinks" } span { class: "backlinks-count", "({count})" } // Reindex button button { class: "backlinks-reindex-btn", title: "Re-extract links from this note", disabled: is_reindexing, onclick: on_reindex, if is_reindexing { span { class: "spinner-tiny" } } else { "\u{21bb}" // Refresh symbol } } } if !is_collapsed { div { class: "backlinks-content", // Show reindex message if present if let Some((ref msg, is_err)) = *reindex_message.read() { div { class: if is_err { "backlinks-message error" } else { "backlinks-message success" }, "{msg}" } } if is_loading { div { class: "backlinks-loading", div { class: "spinner-small" } "Loading backlinks..." } } if let Some(ref err) = *error.read() { div { class: "backlinks-error", "{err}" } } if !is_loading && error.read().is_none() { if let Some(ref data) = *backlink_data { if data.backlinks.is_empty() { div { class: "backlinks-empty", "No other notes link to this one." } } else { ul { class: "backlinks-list", for backlink in &data.backlinks { BacklinkItemView { backlink: backlink.clone(), on_navigate: on_navigate.clone(), } } } } } } } } } } } /// Individual backlink item view. #[component] fn BacklinkItemView( backlink: BacklinkItem, on_navigate: EventHandler, ) -> Element { let source_id = backlink.source_id.clone(); let title = backlink .source_title .clone() .unwrap_or_else(|| backlink.source_path.clone()); let context = backlink.context.clone(); let line_number = backlink.line_number; let link_type = backlink.link_type.clone(); rsx! { li { class: "backlink-item", onclick: move |_| on_navigate.call(source_id.clone()), div { class: "backlink-source", span { class: "backlink-title", "{title}" } span { class: "backlink-type-badge backlink-type-{link_type}", "{link_type}" } } if let Some(ref ctx) = context { div { class: "backlink-context", if let Some(ln) = line_number { span { class: "backlink-line", "L{ln}: " } } "\"{ctx}\"" } } } } } /// Outgoing links panel showing what this note links to. #[component] pub fn OutgoingLinksPanel( media_id: String, client: ApiClient, on_navigate: EventHandler, ) -> Element { let mut links = use_signal(|| Option::::None); let mut loading = use_signal(|| true); let mut error = use_signal(|| Option::::None); let mut collapsed = use_signal(|| true); // Collapsed by default let mut global_unresolved = use_signal(|| Option::::None); // Fetch outgoing links on mount let id = media_id.clone(); let client_clone = client.clone(); use_effect(move || { let id = id.clone(); let client = client_clone.clone(); spawn(async move { loading.set(true); error.set(None); match client.get_outgoing_links(&id).await { Ok(resp) => { links.set(Some(resp)); } Err(e) => { error.set(Some(format!("Failed to load links: {e}"))); } } loading.set(false); // Also fetch global unresolved count if let Ok(count) = client.get_unresolved_links_count().await { global_unresolved.set(Some(count)); } }); }); let is_loading = *loading.read(); let is_collapsed = *collapsed.read(); let link_data = links.read(); let count = link_data.as_ref().map(|l| l.count).unwrap_or(0); let unresolved_in_note = link_data .as_ref() .map(|l| l.links.iter().filter(|link| !link.is_resolved).count()) .unwrap_or(0); rsx! { div { class: "outgoing-links-panel", // Header with toggle div { class: "outgoing-links-header", onclick: move |_| { let current = *collapsed.read(); collapsed.set(!current); }, span { class: "outgoing-links-toggle", if is_collapsed { "\u{25b6}" } else { "\u{25bc}" } } span { class: "outgoing-links-title", "Outgoing Links" } span { class: "outgoing-links-count", "({count})" } if unresolved_in_note > 0 { span { class: "outgoing-links-unresolved-badge", title: "Unresolved links in this note", "{unresolved_in_note} unresolved" } } } if !is_collapsed { div { class: "outgoing-links-content", if is_loading { div { class: "outgoing-links-loading", div { class: "spinner-small" } "Loading links..." } } if let Some(ref err) = *error.read() { div { class: "outgoing-links-error", "{err}" } } if !is_loading && error.read().is_none() { if let Some(ref data) = *link_data { if data.links.is_empty() { div { class: "outgoing-links-empty", "This note has no outgoing links." } } else { ul { class: "outgoing-links-list", for link in &data.links { OutgoingLinkItemView { link: link.clone(), on_navigate: on_navigate.clone(), } } } } } // Show global unresolved count if any if let Some(global_count) = *global_unresolved.read() { if global_count > 0 { div { class: "outgoing-links-global-unresolved", span { class: "unresolved-icon", "\u{26a0}" } " {global_count} unresolved links across all notes" } } } } } } } } } /// Individual outgoing link item view. #[component] fn OutgoingLinkItemView( link: crate::client::OutgoingLinkItem, on_navigate: EventHandler, ) -> Element { let target_id = link.target_id.clone(); let target_path = link.target_path.clone(); let link_text = link.link_text.clone(); let is_resolved = link.is_resolved; let link_type = link.link_type.clone(); let display_text = link_text.unwrap_or_else(|| target_path.clone()); let resolved_class = if is_resolved { "resolved" } else { "unresolved" }; rsx! { li { class: "outgoing-link-item {resolved_class}", onclick: move |_| { if let Some(ref id) = target_id { on_navigate.call(id.clone()); } }, div { class: "outgoing-link-target", span { class: "outgoing-link-text", "{display_text}" } span { class: "outgoing-link-type-badge link-type-{link_type}", "{link_type}" } if !is_resolved { span { class: "unresolved-badge", "unresolved" } } } } } }