various: markdown improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
This commit is contained in:
parent
875bdf5ebc
commit
80a8b5c7ca
23 changed files with 3458 additions and 30 deletions
345
crates/pinakes-ui/src/components/backlinks_panel.rs
Normal file
345
crates/pinakes-ui/src/components/backlinks_panel.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
//! 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue