pinakes/crates/pinakes-ui/src/components/backlinks_panel.rs
NotAShelf 80a8b5c7ca
various: markdown improvements
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
2026-02-09 15:49:23 +03:00

345 lines
12 KiB
Rust

//! 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<String>,
) -> Element {
let mut backlinks = use_signal(|| Option::<BacklinksResponse>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::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<String>,
) -> 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<String>,
) -> Element {
let mut links = use_signal(|| Option::<crate::client::OutgoingLinksResponse>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
let mut collapsed = use_signal(|| true); // Collapsed by default
let mut global_unresolved = use_signal(|| Option::<u64>::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<String>,
) -> 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" }
}
}
}
}
}