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
|
|
@ -20,3 +20,5 @@ futures = { workspace = true }
|
|||
rfd = "0.17"
|
||||
pulldown-cmark = { workspace = true }
|
||||
gray_matter = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
ammonia = "4"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use futures::future::join_all;
|
|||
|
||||
use crate::client::*;
|
||||
use crate::components::{
|
||||
audit, collections, database, detail, duplicates, import, library,
|
||||
audit, collections, database, detail, duplicates, graph_view, import, library,
|
||||
media_player::PlayQueue,
|
||||
search, settings, statistics, tags, tasks,
|
||||
};
|
||||
|
|
@ -29,6 +29,7 @@ enum View {
|
|||
Tasks,
|
||||
Settings,
|
||||
Database,
|
||||
Graph,
|
||||
}
|
||||
|
||||
impl View {
|
||||
|
|
@ -46,6 +47,7 @@ impl View {
|
|||
Self::Tasks => "Tasks",
|
||||
Self::Settings => "Settings",
|
||||
Self::Database => "Database",
|
||||
Self::Graph => "Note Graph",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -564,6 +566,14 @@ pub fn App() -> Element {
|
|||
span { class: "nav-icon", "\u{1f4ca}" }
|
||||
span { class: "nav-item-text", "Statistics" }
|
||||
}
|
||||
button {
|
||||
class: if *current_view.read() == View::Graph { "nav-item active" } else { "nav-item" },
|
||||
onclick: move |_| {
|
||||
current_view.set(View::Graph);
|
||||
},
|
||||
span { class: "nav-icon", "\u{1f578}" }
|
||||
span { class: "nav-item-text", "Graph" }
|
||||
}
|
||||
button {
|
||||
class: if *current_view.read() == View::Tasks { "nav-item active" } else { "nav-item" },
|
||||
onclick: {
|
||||
|
|
@ -1310,6 +1320,25 @@ pub fn App() -> Element {
|
|||
show_toast("Added to queue".into(), false);
|
||||
}
|
||||
},
|
||||
on_navigate_to_media: {
|
||||
let client = client.read().clone();
|
||||
move |media_id: String| {
|
||||
let client = client.clone();
|
||||
spawn(async move {
|
||||
match client.get_media(&media_id).await {
|
||||
Ok(media) => {
|
||||
// Load tags for the new media
|
||||
if let Ok(mtags) = client.get_media_tags(&media_id).await {
|
||||
media_tags.set(mtags);
|
||||
}
|
||||
selected_media.set(Some(media));
|
||||
auto_play_media.set(false);
|
||||
}
|
||||
Err(e) => show_toast(format!("Failed to load linked note: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
None => rsx! {
|
||||
|
|
@ -2305,6 +2334,33 @@ pub fn App() -> Element {
|
|||
},
|
||||
}
|
||||
}
|
||||
View::Graph => {
|
||||
rsx! {
|
||||
graph_view::GraphView {
|
||||
client: client.read().clone(),
|
||||
center_id: None,
|
||||
on_navigate: {
|
||||
let client = client.read().clone();
|
||||
move |media_id: String| {
|
||||
let client = client.clone();
|
||||
spawn(async move {
|
||||
match client.get_media(&media_id).await {
|
||||
Ok(media) => {
|
||||
// Load tags for the media
|
||||
if let Ok(mtags) = client.get_media_tags(&media_id).await {
|
||||
media_tags.set(mtags);
|
||||
}
|
||||
selected_media.set(Some(media));
|
||||
current_view.set(View::Detail);
|
||||
}
|
||||
Err(e) => show_toast(format!("Failed to load: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,6 +277,74 @@ pub struct DatabaseStatsResponse {
|
|||
pub backend_name: String,
|
||||
}
|
||||
|
||||
// ── Markdown Notes/Links Response Types ──
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct BacklinksResponse {
|
||||
pub backlinks: Vec<BacklinkItem>,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct BacklinkItem {
|
||||
pub link_id: String,
|
||||
pub source_id: String,
|
||||
pub source_title: Option<String>,
|
||||
pub source_path: String,
|
||||
pub link_text: Option<String>,
|
||||
pub line_number: Option<i32>,
|
||||
pub context: Option<String>,
|
||||
pub link_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct OutgoingLinksResponse {
|
||||
pub links: Vec<OutgoingLinkItem>,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct OutgoingLinkItem {
|
||||
pub id: String,
|
||||
pub target_path: String,
|
||||
pub target_id: Option<String>,
|
||||
pub link_text: Option<String>,
|
||||
pub line_number: Option<i32>,
|
||||
pub link_type: String,
|
||||
pub is_resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct GraphResponse {
|
||||
pub nodes: Vec<GraphNodeResponse>,
|
||||
pub edges: Vec<GraphEdgeResponse>,
|
||||
pub node_count: usize,
|
||||
pub edge_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct GraphNodeResponse {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub title: Option<String>,
|
||||
pub media_type: String,
|
||||
pub link_count: u32,
|
||||
pub backlink_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct GraphEdgeResponse {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub link_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct ReindexLinksResponse {
|
||||
pub message: String,
|
||||
pub links_extracted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct SavedSearchResponse {
|
||||
pub id: String,
|
||||
|
|
@ -1073,6 +1141,85 @@ impl ApiClient {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ── Markdown Notes/Links ──
|
||||
|
||||
/// Get backlinks (incoming links) to a media item.
|
||||
pub async fn get_backlinks(&self, id: &str) -> Result<BacklinksResponse> {
|
||||
Ok(self
|
||||
.client
|
||||
.get(self.url(&format!("/media/{id}/backlinks")))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Get outgoing links from a media item.
|
||||
pub async fn get_outgoing_links(&self, id: &str) -> Result<OutgoingLinksResponse> {
|
||||
Ok(self
|
||||
.client
|
||||
.get(self.url(&format!("/media/{id}/outgoing-links")))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Get graph data for visualization.
|
||||
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 {
|
||||
query_parts.push(format!("center={}", center));
|
||||
}
|
||||
if let Some(d) = depth {
|
||||
query_parts.push(format!("depth={}", d));
|
||||
}
|
||||
if !query_parts.is_empty() {
|
||||
url = format!("{}?{}", url, query_parts.join("&"));
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Re-extract links from a media item.
|
||||
pub async fn reindex_links(&self, id: &str) -> Result<ReindexLinksResponse> {
|
||||
Ok(self
|
||||
.client
|
||||
.post(self.url(&format!("/media/{id}/reindex-links")))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Get count of unresolved links.
|
||||
pub async fn get_unresolved_links_count(&self) -> Result<u64> {
|
||||
#[derive(Deserialize)]
|
||||
struct CountResp {
|
||||
count: u64,
|
||||
}
|
||||
let resp: CountResp = self
|
||||
.client
|
||||
.get(self.url("/notes/unresolved-count"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(resp.count)
|
||||
}
|
||||
|
||||
pub fn set_token(&mut self, token: &str) {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) {
|
||||
|
|
|
|||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::backlinks_panel::{BacklinksPanel, OutgoingLinksPanel};
|
||||
use super::image_viewer::ImageViewer;
|
||||
use super::markdown_viewer::MarkdownViewer;
|
||||
use super::media_player::{MediaPlayer, PlayQueue, QueueItem, QueuePanel};
|
||||
|
|
@ -23,6 +24,7 @@ pub fn Detail(
|
|||
on_set_custom_field: EventHandler<(String, String, String, String)>,
|
||||
on_delete_custom_field: EventHandler<(String, String)>,
|
||||
on_delete: EventHandler<String>,
|
||||
#[props(default)] on_navigate_to_media: Option<EventHandler<String>>,
|
||||
#[props(default)] on_queue_select: Option<EventHandler<usize>>,
|
||||
#[props(default)] on_queue_remove: Option<EventHandler<usize>>,
|
||||
#[props(default)] on_queue_clear: Option<EventHandler<()>>,
|
||||
|
|
@ -751,6 +753,43 @@ pub fn Detail(
|
|||
}
|
||||
}
|
||||
|
||||
// Backlinks and outgoing links panels for markdown/text files
|
||||
if category == "text" {
|
||||
{
|
||||
let client_for_backlinks = client.clone();
|
||||
let client_for_outgoing = client.clone();
|
||||
let media_id_for_backlinks = id.clone();
|
||||
let media_id_for_outgoing = id.clone();
|
||||
let nav_handler = on_navigate_to_media;
|
||||
rsx! {
|
||||
BacklinksPanel {
|
||||
media_id: media_id_for_backlinks,
|
||||
client: client_for_backlinks,
|
||||
on_navigate: {
|
||||
let handler = nav_handler;
|
||||
move |target_id: String| {
|
||||
if let Some(ref h) = handler {
|
||||
h.call(target_id);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
OutgoingLinksPanel {
|
||||
media_id: media_id_for_outgoing,
|
||||
client: client_for_outgoing,
|
||||
on_navigate: {
|
||||
let handler = nav_handler;
|
||||
move |target_id: String| {
|
||||
if let Some(ref h) = handler {
|
||||
h.call(target_id);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image viewer overlay
|
||||
if *show_image_viewer.read() {
|
||||
ImageViewer {
|
||||
|
|
|
|||
295
crates/pinakes-ui/src/components/graph_view.rs
Normal file
295
crates/pinakes-ui/src/components/graph_view.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
//! Graph visualization component for markdown note connections.
|
||||
//!
|
||||
//! Renders a force-directed graph showing connections between notes.
|
||||
//! Uses a simple SVG-based rendering approach (no D3.js dependency).
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse};
|
||||
|
||||
/// Graph view component showing note connections.
|
||||
#[component]
|
||||
pub fn GraphView(
|
||||
client: ApiClient,
|
||||
center_id: Option<String>,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut graph_data = use_signal(|| Option::<GraphResponse>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut depth = use_signal(|| 2u32);
|
||||
let mut selected_node = use_signal(|| Option::<String>::None);
|
||||
|
||||
// Fetch graph data
|
||||
let center = center_id.clone();
|
||||
let d = *depth.read();
|
||||
let client_clone = client.clone();
|
||||
use_effect(move || {
|
||||
let center = center.clone();
|
||||
let client = client_clone.clone();
|
||||
spawn(async move {
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
match client.get_graph(center.as_deref(), Some(d)).await {
|
||||
Ok(resp) => {
|
||||
graph_data.set(Some(resp));
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(format!("Failed to load graph: {e}")));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let is_loading = *loading.read();
|
||||
let current_depth = *depth.read();
|
||||
let data = graph_data.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "graph-view",
|
||||
// Toolbar
|
||||
div { class: "graph-toolbar",
|
||||
span { class: "graph-title", "Note Graph" }
|
||||
div { class: "graph-controls",
|
||||
label { "Depth: " }
|
||||
select {
|
||||
value: "{current_depth}",
|
||||
onchange: move |evt| {
|
||||
if let Ok(d) = evt.value().parse::<u32>() {
|
||||
depth.set(d);
|
||||
}
|
||||
},
|
||||
option { value: "1", "1" }
|
||||
option { value: "2", "2" }
|
||||
option { value: "3", "3" }
|
||||
option { value: "4", "4" }
|
||||
option { value: "5", "5" }
|
||||
}
|
||||
}
|
||||
if let Some(ref data) = *data {
|
||||
div { class: "graph-stats",
|
||||
"{data.node_count} nodes, {data.edge_count} edges"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Graph container
|
||||
div { class: "graph-container",
|
||||
if is_loading {
|
||||
div { class: "graph-loading",
|
||||
div { class: "spinner" }
|
||||
"Loading graph..."
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "graph-error", "{err}" }
|
||||
}
|
||||
|
||||
if !is_loading && error.read().is_none() {
|
||||
if let Some(ref graph) = *data {
|
||||
if graph.nodes.is_empty() {
|
||||
div { class: "graph-empty",
|
||||
"No linked notes found. Start creating links between your notes!"
|
||||
}
|
||||
} else {
|
||||
GraphSvg {
|
||||
nodes: graph.nodes.clone(),
|
||||
edges: graph.edges.clone(),
|
||||
selected_node: selected_node.clone(),
|
||||
on_node_click: move |id: String| {
|
||||
selected_node.set(Some(id.clone()));
|
||||
},
|
||||
on_node_double_click: move |id: String| {
|
||||
on_navigate.call(id);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node details panel
|
||||
if let Some(ref node_id) = *selected_node.read() {
|
||||
if let Some(ref graph) = *data {
|
||||
if let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) {
|
||||
NodeDetailsPanel {
|
||||
node: node.clone(),
|
||||
on_close: move |_| selected_node.set(None),
|
||||
on_navigate: move |id| {
|
||||
on_navigate.call(id);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG-based graph rendering.
|
||||
#[component]
|
||||
fn GraphSvg(
|
||||
nodes: Vec<GraphNodeResponse>,
|
||||
edges: Vec<GraphEdgeResponse>,
|
||||
selected_node: Signal<Option<String>>,
|
||||
on_node_click: EventHandler<String>,
|
||||
on_node_double_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
// Simple circular layout for nodes
|
||||
let node_count = nodes.len();
|
||||
let width: f64 = 800.0;
|
||||
let height: f64 = 600.0;
|
||||
let center_x = width / 2.0;
|
||||
let center_y = height / 2.0;
|
||||
let radius = (width.min(height) / 2.0) - 60.0;
|
||||
|
||||
// Calculate node positions in a circle
|
||||
let positions: Vec<(f64, f64)> = (0..node_count)
|
||||
.map(|i| {
|
||||
let angle = (i as f64 / node_count as f64) * 2.0 * std::f64::consts::PI;
|
||||
let x = center_x + radius * angle.cos();
|
||||
let y = center_y + radius * angle.sin();
|
||||
(x, y)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create a map from node id to position
|
||||
let id_to_pos: std::collections::HashMap<&str, (f64, f64)> = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.id.as_str(), positions[i]))
|
||||
.collect();
|
||||
|
||||
let selected = selected_node.read();
|
||||
|
||||
rsx! {
|
||||
svg {
|
||||
class: "graph-svg",
|
||||
width: "{width}",
|
||||
height: "{height}",
|
||||
view_box: "0 0 {width} {height}",
|
||||
|
||||
// Draw edges first (so they appear behind nodes)
|
||||
g { class: "graph-edges",
|
||||
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())
|
||||
) {
|
||||
line {
|
||||
class: "graph-edge edge-type-{edge.link_type}",
|
||||
x1: "{x1}",
|
||||
y1: "{y1}",
|
||||
x2: "{x2}",
|
||||
y2: "{y2}",
|
||||
stroke: "#888",
|
||||
stroke_width: "1",
|
||||
marker_end: "url(#arrowhead)",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow marker definition
|
||||
defs {
|
||||
marker {
|
||||
id: "arrowhead",
|
||||
marker_width: "10",
|
||||
marker_height: "7",
|
||||
ref_x: "10",
|
||||
ref_y: "3.5",
|
||||
orient: "auto",
|
||||
polygon {
|
||||
points: "0 0, 10 3.5, 0 7",
|
||||
fill: "#888",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
g { class: "graph-nodes",
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
{
|
||||
let (x, y) = positions[i];
|
||||
let node_id = node.id.clone();
|
||||
let node_id2 = node.id.clone();
|
||||
let label = node.label.clone();
|
||||
let is_selected = selected.as_ref() == Some(&node.id);
|
||||
let node_size = 8.0 + (node.link_count + node.backlink_count) as f64 * 2.0;
|
||||
let node_size = node_size.min(30.0);
|
||||
|
||||
rsx! {
|
||||
g {
|
||||
class: if is_selected { "graph-node selected" } else { "graph-node" },
|
||||
onclick: move |_| on_node_click.call(node_id.clone()),
|
||||
ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()),
|
||||
|
||||
circle {
|
||||
cx: "{x}",
|
||||
cy: "{y}",
|
||||
r: "{node_size}",
|
||||
fill: if is_selected { "#2196f3" } else { "#4caf50" },
|
||||
stroke: if is_selected { "#1565c0" } else { "#388e3c" },
|
||||
stroke_width: "2",
|
||||
}
|
||||
text {
|
||||
x: "{x}",
|
||||
y: "{y + node_size + 15.0}",
|
||||
text_anchor: "middle",
|
||||
font_size: "12",
|
||||
fill: "#333",
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel showing details about the selected node.
|
||||
#[component]
|
||||
fn NodeDetailsPanel(
|
||||
node: GraphNodeResponse,
|
||||
on_close: EventHandler<()>,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let node_id = node.id.clone();
|
||||
|
||||
rsx! {
|
||||
div { class: "node-details-panel",
|
||||
div { class: "node-details-header",
|
||||
h3 { "{node.label}" }
|
||||
button {
|
||||
class: "close-btn",
|
||||
onclick: move |_| on_close.call(()),
|
||||
"\u{2715}"
|
||||
}
|
||||
}
|
||||
div { class: "node-details-content",
|
||||
if let Some(ref title) = node.title {
|
||||
p { class: "node-title", "{title}" }
|
||||
}
|
||||
div { class: "node-stats",
|
||||
span { class: "stat",
|
||||
"Outgoing: "
|
||||
strong { "{node.link_count}" }
|
||||
}
|
||||
span { class: "stat",
|
||||
"Incoming: "
|
||||
strong { "{node.backlink_count}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| on_navigate.call(node_id.clone()),
|
||||
"Open Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
/// Event handler for wikilink clicks. Called with the target note name.
|
||||
pub type WikilinkClickHandler = EventHandler<String>;
|
||||
|
||||
#[component]
|
||||
pub fn MarkdownViewer(content_url: String, media_type: String) -> Element {
|
||||
pub fn MarkdownViewer(
|
||||
content_url: String,
|
||||
media_type: String,
|
||||
#[props(default)] on_wikilink_click: Option<WikilinkClickHandler>,
|
||||
) -> Element {
|
||||
let mut rendered_html = use_signal(String::new);
|
||||
let mut frontmatter_html = use_signal(|| Option::<String>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
|
|
@ -133,6 +140,9 @@ fn pod_to_display(pod: &gray_matter::Pod) -> String {
|
|||
fn render_markdown(text: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
|
||||
// First, convert wikilinks to standard markdown links
|
||||
let text_with_links = convert_wikilinks(text);
|
||||
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
|
|
@ -140,12 +150,47 @@ fn render_markdown(text: &str) -> String {
|
|||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||
|
||||
let parser = Parser::new_ext(text, options);
|
||||
let parser = Parser::new_ext(&text_with_links, options);
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
|
||||
// Strip script tags for safety
|
||||
strip_script_tags(&html_output)
|
||||
// Sanitize HTML using ammonia with a safe allowlist
|
||||
sanitize_html(&html_output)
|
||||
}
|
||||
|
||||
/// Convert wikilinks [[target]] and [[target|display]] to styled HTML links.
|
||||
/// Uses data attributes only - no inline JavaScript for security.
|
||||
fn convert_wikilinks(text: &str) -> String {
|
||||
use regex::Regex;
|
||||
|
||||
// Match embeds ![[target]] first, convert to a placeholder image/embed span
|
||||
let embed_re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap();
|
||||
let text = embed_re.replace_all(text, |caps: ®ex::Captures| {
|
||||
let target = caps.get(1).unwrap().as_str().trim();
|
||||
let alt = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
|
||||
format!(
|
||||
"<span class=\"wikilink-embed\" data-target=\"{}\" title=\"Embed: {}\">[Embed: {}]</span>",
|
||||
escape_html_attr(target),
|
||||
escape_html_attr(target),
|
||||
escape_html(alt)
|
||||
)
|
||||
});
|
||||
|
||||
// Match wikilinks [[target]] or [[target|display]]
|
||||
let wikilink_re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap();
|
||||
let text = wikilink_re.replace_all(&text, |caps: ®ex::Captures| {
|
||||
let target = caps.get(1).unwrap().as_str().trim();
|
||||
let display = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
|
||||
// Create a styled link with data attributes only - no inline JavaScript.
|
||||
// Event handling is done via event delegation in the frontend.
|
||||
format!(
|
||||
"<a href=\"#wikilink\" class=\"wikilink\" data-wikilink-target=\"{}\">{}</a>",
|
||||
escape_html_attr(target),
|
||||
escape_html(display)
|
||||
)
|
||||
});
|
||||
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
fn render_plaintext(text: &str) -> String {
|
||||
|
|
@ -153,6 +198,7 @@ fn render_plaintext(text: &str) -> String {
|
|||
format!("<pre><code>{escaped}</code></pre>")
|
||||
}
|
||||
|
||||
/// Escape text for display in HTML content.
|
||||
fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
|
|
@ -160,21 +206,59 @@ fn escape_html(text: &str) -> String {
|
|||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn strip_script_tags(html: &str) -> String {
|
||||
// Simple removal of <script> tags
|
||||
let mut result = html.to_string();
|
||||
while let Some(start) = result.to_lowercase().find("<script") {
|
||||
if let Some(end) = result.to_lowercase()[start..].find("</script>") {
|
||||
result = format!(
|
||||
"{}{}",
|
||||
&result[..start],
|
||||
&result[start + end + "</script>".len()..]
|
||||
);
|
||||
} else {
|
||||
// Malformed script tag - remove to end
|
||||
result = result[..start].to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
/// Escape text for use in HTML attributes (includes single quotes).
|
||||
fn escape_html_attr(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Sanitize HTML using ammonia with a safe allowlist.
|
||||
/// This prevents XSS attacks by removing dangerous elements and attributes.
|
||||
fn sanitize_html(html: &str) -> String {
|
||||
use ammonia::Builder;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Build a custom sanitizer that allows safe markdown elements
|
||||
// but strips all event handlers and dangerous elements
|
||||
let mut builder = Builder::default();
|
||||
|
||||
// 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",
|
||||
// Task list support
|
||||
"input",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Allow safe attributes
|
||||
let allowed_attrs: HashSet<&str> = [
|
||||
"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",
|
||||
// Task list checkbox support
|
||||
"type", "checked", "disabled",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
builder
|
||||
.tags(allowed_tags)
|
||||
.generic_attributes(allowed_attrs)
|
||||
// Allow relative URLs and fragment-only URLs for internal links
|
||||
.url_schemes(["http", "https", "mailto"].into_iter().collect())
|
||||
.link_rel(Some("noopener noreferrer"))
|
||||
// Strip all event handler attributes (onclick, onerror, etc.)
|
||||
.strip_comments(true)
|
||||
.clean(html)
|
||||
.to_string()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
pub mod audit;
|
||||
pub mod backlinks_panel;
|
||||
pub mod breadcrumb;
|
||||
pub mod collections;
|
||||
pub mod database;
|
||||
pub mod detail;
|
||||
pub mod duplicates;
|
||||
pub mod graph_view;
|
||||
pub mod image_viewer;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
|
|
|
|||
|
|
@ -3129,4 +3129,503 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
|||
.theme-light .pdf-container {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
/* ── Backlinks Panel ── */
|
||||
.backlinks-panel,
|
||||
.outgoing-links-panel {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.backlinks-header,
|
||||
.outgoing-links-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-3);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.backlinks-header:hover,
|
||||
.outgoing-links-header:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.backlinks-toggle,
|
||||
.outgoing-links-toggle {
|
||||
font-size: 10px;
|
||||
color: var(--text-2);
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.backlinks-title,
|
||||
.outgoing-links-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-0);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.backlinks-count,
|
||||
.outgoing-links-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.backlinks-reindex-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.backlinks-reindex-btn:hover:not(:disabled) {
|
||||
background: var(--bg-2);
|
||||
color: var(--text-0);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.backlinks-reindex-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner-tiny {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.backlinks-message {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.backlinks-message.success {
|
||||
background: rgba(62, 201, 122, 0.08);
|
||||
border: 1px solid rgba(62, 201, 122, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.backlinks-message.error {
|
||||
background: rgba(228, 88, 88, 0.06);
|
||||
border: 1px solid rgba(228, 88, 88, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.outgoing-links-unresolved-badge {
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
background: rgba(212, 160, 55, 0.12);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.outgoing-links-global-unresolved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(212, 160, 55, 0.06);
|
||||
border: 1px solid rgba(212, 160, 55, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.outgoing-links-global-unresolved .unresolved-icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.backlinks-content,
|
||||
.outgoing-links-content {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.backlinks-loading,
|
||||
.outgoing-links-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.backlinks-error,
|
||||
.outgoing-links-error {
|
||||
padding: 8px 12px;
|
||||
background: rgba(228, 88, 88, 0.06);
|
||||
border: 1px solid rgba(228, 88, 88, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.backlinks-empty,
|
||||
.outgoing-links-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.backlinks-list,
|
||||
.outgoing-links-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.backlink-item,
|
||||
.outgoing-link-item {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.backlink-item:hover,
|
||||
.outgoing-link-item:hover {
|
||||
background: var(--bg-1);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.backlink-source,
|
||||
.outgoing-link-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.backlink-title,
|
||||
.outgoing-link-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-0);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backlink-type-badge,
|
||||
.outgoing-link-type-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.backlink-type-wikilink,
|
||||
.link-type-wikilink {
|
||||
background: rgba(124, 126, 245, 0.1);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.backlink-type-embed,
|
||||
.link-type-embed {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #9d8be0;
|
||||
}
|
||||
|
||||
.backlink-type-markdown_link,
|
||||
.link-type-markdown_link {
|
||||
background: rgba(59, 120, 200, 0.1);
|
||||
color: #6ca0d4;
|
||||
}
|
||||
|
||||
.backlink-context {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.backlink-line {
|
||||
color: var(--text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unresolved-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
background: rgba(212, 160, 55, 0.1);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.outgoing-link-item.unresolved {
|
||||
opacity: 0.7;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Graph View ── */
|
||||
.graph-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-1);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.graph-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-0);
|
||||
}
|
||||
|
||||
.graph-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.graph-controls select {
|
||||
padding: 4px 20px 4px 8px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-3);
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
|
||||
.graph-loading,
|
||||
.graph-error,
|
||||
.graph-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-svg {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.graph-edges line {
|
||||
stroke: var(--border-strong);
|
||||
stroke-width: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.graph-edges line.edge-type-wikilink {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.graph-edges line.edge-type-embed {
|
||||
stroke: #9d8be0;
|
||||
stroke-dasharray: 4 2;
|
||||
}
|
||||
|
||||
.graph-nodes .graph-node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.graph-nodes .graph-node circle {
|
||||
fill: #4caf50;
|
||||
stroke: #388e3c;
|
||||
stroke-width: 2;
|
||||
transition: fill 0.15s, stroke 0.15s;
|
||||
}
|
||||
|
||||
.graph-nodes .graph-node:hover circle {
|
||||
fill: #66bb6a;
|
||||
}
|
||||
|
||||
.graph-nodes .graph-node.selected circle {
|
||||
fill: var(--accent);
|
||||
stroke: #5456d6;
|
||||
}
|
||||
|
||||
.graph-nodes .graph-node text {
|
||||
fill: var(--text-1);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Node Details Panel ── */
|
||||
.node-details-panel {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 280px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.node-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.node-details-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-0);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-details-panel .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.node-details-panel .close-btn:hover {
|
||||
color: var(--text-0);
|
||||
}
|
||||
|
||||
.node-details-content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.node-details-content .node-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-1);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.node-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.node-stats .stat {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.node-stats .stat strong {
|
||||
color: var(--text-0);
|
||||
}
|
||||
|
||||
/* ── Wikilink Styles (in markdown) ── */
|
||||
.wikilink {
|
||||
color: var(--accent-text);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed var(--accent);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.wikilink:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
.wikilink-embed {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border: 1px dashed rgba(139, 92, 246, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #9d8be0;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Light theme adjustments for links and graph ── */
|
||||
.theme-light .graph-nodes .graph-node text {
|
||||
fill: var(--text-0);
|
||||
}
|
||||
|
||||
.theme-light .graph-edges line {
|
||||
stroke: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
"#;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue