pinakes-ui: add graph view, backlinks panel, and link extraction

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ibf40b009f5d18d16fc115b349b1f681d6a6a6964
This commit is contained in:
raf 2026-02-09 13:14:57 +03:00
commit 3e1e8dea26
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 103 additions and 43 deletions

View file

@ -1168,7 +1168,11 @@ impl ApiClient {
}
/// Get graph data for visualization.
pub async fn get_graph(&self, center_id: Option<&str>, depth: Option<u32>) -> Result<GraphResponse> {
pub async fn get_graph(
&self,
center_id: Option<&str>,
depth: Option<u32>,
) -> Result<GraphResponse> {
let mut url = self.url("/notes/graph");
let mut query_parts = Vec::new();
if let Some(center) = center_id {

View file

@ -94,7 +94,11 @@ pub fn BacklinksPanel(
collapsed.set(!current);
},
span { class: "backlinks-toggle",
if is_collapsed { "\u{25b6}" } else { "\u{25bc}" }
if is_collapsed {
"\u{25b6}"
} else {
"\u{25bc}"
}
}
span { class: "backlinks-title", "Backlinks" }
span { class: "backlinks-count", "({count})" }
@ -116,8 +120,7 @@ pub fn BacklinksPanel(
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" },
div { class: if is_err { "backlinks-message error" } else { "backlinks-message success" },
"{msg}"
}
}
@ -136,9 +139,7 @@ pub fn BacklinksPanel(
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."
}
div { class: "backlinks-empty", "No other notes link to this one." }
} else {
ul { class: "backlinks-list",
for backlink in &data.backlinks {
@ -159,10 +160,7 @@ pub fn BacklinksPanel(
/// Individual backlink item view.
#[component]
fn BacklinkItemView(
backlink: BacklinkItem,
on_navigate: EventHandler<String>,
) -> Element {
fn BacklinkItemView(backlink: BacklinkItem, on_navigate: EventHandler<String>) -> Element {
let source_id = backlink.source_id.clone();
let title = backlink
.source_title
@ -250,7 +248,11 @@ pub fn OutgoingLinksPanel(
collapsed.set(!current);
},
span { class: "outgoing-links-toggle",
if is_collapsed { "\u{25b6}" } else { "\u{25bc}" }
if is_collapsed {
"\u{25b6}"
} else {
"\u{25bc}"
}
}
span { class: "outgoing-links-title", "Outgoing Links" }
span { class: "outgoing-links-count", "({count})" }
@ -279,9 +281,7 @@ pub fn OutgoingLinksPanel(
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."
}
div { class: "outgoing-links-empty", "This note has no outgoing links." }
} else {
ul { class: "outgoing-links-list",
for link in &data.links {
@ -323,7 +323,11 @@ fn OutgoingLinkItemView(
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" };
let resolved_class = if is_resolved {
"resolved"
} else {
"unresolved"
};
rsx! {
li {

View file

@ -68,9 +68,7 @@ pub fn GraphView(
}
}
if let Some(ref data) = *data {
div { class: "graph-stats",
"{data.node_count} nodes, {data.edge_count} edges"
}
div { class: "graph-stats", "{data.node_count} nodes, {data.edge_count} edges" }
}
}
@ -176,8 +174,9 @@ fn GraphSvg(
for edge in &edges {
if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
id_to_pos.get(edge.source.as_str()),
id_to_pos.get(edge.target.as_str())
) {
id_to_pos.get(edge.target.as_str()),
)
{
line {
class: "graph-edge edge-type-{edge.link_type}",
x1: "{x1}",
@ -201,16 +200,13 @@ fn GraphSvg(
ref_x: "10",
ref_y: "3.5",
orient: "auto",
polygon {
points: "0 0, 10 3.5, 0 7",
fill: "#888",
}
polygon { points: "0 0, 10 3.5, 0 7", fill: "#888" }
}
}
// Draw nodes
g { class: "graph-nodes",
for (i, node) in nodes.iter().enumerate() {
for (i , node) in nodes.iter().enumerate() {
{
let (x, y) = positions[i];
let node_id = node.id.clone();
@ -226,6 +222,7 @@ fn GraphSvg(
onclick: move |_| on_node_click.call(node_id.clone()),
ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()),
circle {
cx: "{x}",
cy: "{y}",
@ -264,11 +261,7 @@ fn NodeDetailsPanel(
div { class: "node-details-panel",
div { class: "node-details-header",
h3 { "{node.label}" }
button {
class: "close-btn",
onclick: move |_| on_close.call(()),
"\u{2715}"
}
button { class: "close-btn", onclick: move |_| on_close.call(()), "\u{2715}" }
}
div { class: "node-details-content",
if let Some(ref title) = node.title {

View file

@ -227,11 +227,55 @@ fn sanitize_html(html: &str) -> String {
// Allow common markdown elements
let allowed_tags: HashSet<&str> = [
"a", "abbr", "acronym", "b", "blockquote", "br", "code", "dd", "del",
"details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6",
"hr", "i", "img", "ins", "kbd", "li", "mark", "ol", "p", "pre", "q",
"s", "samp", "small", "span", "strong", "sub", "summary", "sup",
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul", "var",
"a",
"abbr",
"acronym",
"b",
"blockquote",
"br",
"code",
"dd",
"del",
"details",
"div",
"dl",
"dt",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"ins",
"kbd",
"li",
"mark",
"ol",
"p",
"pre",
"q",
"s",
"samp",
"small",
"span",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"u",
"ul",
"var",
// Task list support
"input",
]
@ -240,13 +284,27 @@ fn sanitize_html(html: &str) -> String {
// Allow safe attributes
let allowed_attrs: HashSet<&str> = [
"href", "src", "alt", "title", "class", "id", "name",
"width", "height", "align", "valign",
"colspan", "rowspan", "scope",
"href",
"src",
"alt",
"title",
"class",
"id",
"name",
"width",
"height",
"align",
"valign",
"colspan",
"rowspan",
"scope",
// Data attributes for wikilinks (safe - no code execution)
"data-target", "data-wikilink-target",
"data-target",
"data-wikilink-target",
// Task list checkbox support
"type", "checked", "disabled",
"type",
"checked",
"disabled",
]
.into_iter()
.collect();