474ed1fe05
Card & row interactions: - NoteCard, TaskCard, ProjectCard: translateY(-2px) lift on hover + extended transitions - Kanban task cards (ProjectView), note rows: same lift treatment - Task rows in flat/grouped list: soft indigo bg tint on hover - Chat conversation items: left-border accent + bg tint on hover (was border-only) - HomeView project mini-cards: translateY(-2px) lift on hover Global feedback: - Buttons: scale(0.97) on :active press (theme.css, :not(:disabled) scoped) - TagPill: color → primary + indigo bg tint on hover - StatusBadge: increased opacity 15% → 22% + stronger text color for readability Loading & empty states: - TasksListView: shimmer skeleton loader (6 rows) + rich empty state with CTA - ProjectListView: shimmer skeleton cards (4) + rich empty state with CTA - HomeView: section-empty messages for overdue/high-priority/notes sections - ProjectView kanban columns: dashed-border styled empty state Transitions & graph: - WorkspaceView panels: panel-fade opacity transition on show/hide (v-if inner wrap) - GraphView D3 nodes: grow to r*1.3 + bold label on mouseover (150ms transition) - Milestone action buttons: base opacity 0 → 0.35 for discoverability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
948 lines
26 KiB
Vue
948 lines
26 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { forceSimulation, forceLink, forceManyBody, forceX, forceY, forceCollide, SimulationNodeDatum, SimulationLinkDatum, Simulation } from "d3-force";
|
|
import { zoom, zoomIdentity } from "d3-zoom";
|
|
import { drag } from "d3-drag";
|
|
import { select } from "d3-selection";
|
|
import { apiGet } from "@/api/client";
|
|
import { renderMarkdown } from "@/utils/markdown";
|
|
|
|
const router = useRouter();
|
|
|
|
interface GraphNode extends SimulationNodeDatum {
|
|
id: number | string;
|
|
title: string;
|
|
type: "note" | "task" | "tag";
|
|
tags: string[];
|
|
project_id: number | null;
|
|
project_color: string | null;
|
|
// computed
|
|
degree?: number;
|
|
radius?: number;
|
|
}
|
|
|
|
interface GraphEdge {
|
|
source: number | string | GraphNode;
|
|
target: number | string | GraphNode;
|
|
type: "wikilink" | "tag";
|
|
}
|
|
|
|
interface Project {
|
|
id: number;
|
|
title: string;
|
|
color: string | null;
|
|
}
|
|
|
|
const svgRef = ref<SVGSVGElement | null>(null);
|
|
const containerRef = ref<HTMLElement | null>(null);
|
|
|
|
const loading = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const nodes = ref<GraphNode[]>([]);
|
|
const edges = ref<GraphEdge[]>([]);
|
|
const projects = ref<Project[]>([]);
|
|
|
|
const projectFilter = ref<number | null>(null);
|
|
const showSharedTags = ref(true);
|
|
const hideOrphans = ref(false);
|
|
|
|
// Physics tuning knobs
|
|
const showPhysics = ref(false);
|
|
const repulsion = ref(110); // displayed as positive; applied as negative charge
|
|
const linkDist = ref(80);
|
|
const linkStrength = ref(0.50);
|
|
const hubPull = ref(0.05);
|
|
const gravity = ref(0.03); // forceX/Y strength — 0 = no pull, ~0.1 = strong
|
|
|
|
// tooltip state
|
|
const tooltip = ref<{ visible: boolean; x: number; y: number; node: GraphNode | null }>({
|
|
visible: false,
|
|
x: 0,
|
|
y: 0,
|
|
node: null,
|
|
});
|
|
|
|
// peek panel state
|
|
const peekNode = ref<GraphNode | null>(null);
|
|
const peekBody = ref("");
|
|
const peekLoading = ref(false);
|
|
|
|
const peekLinkedNodes = computed<GraphNode[]>(() => {
|
|
if (!peekNode.value) return [];
|
|
const pid = String(peekNode.value.id);
|
|
const linkedIds = new Set<string>();
|
|
for (const e of edges.value) {
|
|
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
|
|
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
|
|
if (sid === pid) linkedIds.add(tid);
|
|
if (tid === pid) linkedIds.add(sid);
|
|
}
|
|
return nodes.value.filter(
|
|
(n) => linkedIds.has(String(n.id)) && n.type !== "tag"
|
|
);
|
|
});
|
|
|
|
async function openPeek(node: GraphNode) {
|
|
peekNode.value = node;
|
|
peekBody.value = "";
|
|
peekLoading.value = true;
|
|
try {
|
|
const endpoint = node.type === "task" ? `/api/tasks/${node.id}` : `/api/notes/${node.id}`;
|
|
const data = await apiGet<{ body?: string }>(endpoint);
|
|
peekBody.value = data.body ?? "";
|
|
} catch {
|
|
peekBody.value = "";
|
|
} finally {
|
|
peekLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function closePeek() {
|
|
peekNode.value = null;
|
|
}
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === "Escape" && peekNode.value) {
|
|
closePeek();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
let simulation: Simulation<GraphNode, undefined> | null = null;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
|
|
const statsText = computed(() => {
|
|
if (!nodes.value.length) return "";
|
|
const orphanCount = nodes.value.filter((n) => (n.degree ?? 0) === 0).length;
|
|
const visibleNodes = hideOrphans.value ? nodes.value.length - orphanCount : nodes.value.length;
|
|
return `${visibleNodes} nodes · ${edges.value.length} edges`;
|
|
});
|
|
|
|
async function fetchData() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (projectFilter.value !== null) params.set("project_id", String(projectFilter.value));
|
|
if (showSharedTags.value) params.set("shared_tags", "true");
|
|
const qs = params.toString() ? "?" + params.toString() : "";
|
|
const data = await apiGet<{ nodes: GraphNode[]; edges: GraphEdge[] }>(`/api/notes/graph${qs}`);
|
|
nodes.value = data.nodes;
|
|
edges.value = data.edges;
|
|
} catch (e: any) {
|
|
error.value = e?.message ?? "Failed to load graph";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function fetchProjects() {
|
|
try {
|
|
const data = await apiGet<{ projects: Project[] }>("/api/projects");
|
|
projects.value = data.projects ?? [];
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
}
|
|
|
|
function initGraph() {
|
|
if (!svgRef.value || !containerRef.value) return;
|
|
|
|
const svg = select(svgRef.value);
|
|
svg.selectAll("*").remove();
|
|
|
|
const w = containerRef.value.clientWidth || 800;
|
|
const h = containerRef.value.clientHeight || 600;
|
|
|
|
// Compute degrees
|
|
const degreeMap = new Map<string, number>();
|
|
for (const n of nodes.value) degreeMap.set(String(n.id), 0);
|
|
for (const e of edges.value) {
|
|
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
|
|
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
|
|
degreeMap.set(sid, (degreeMap.get(sid) ?? 0) + 1);
|
|
degreeMap.set(tid, (degreeMap.get(tid) ?? 0) + 1);
|
|
}
|
|
|
|
const simNodes: GraphNode[] = nodes.value.map((n) => {
|
|
const deg = degreeMap.get(String(n.id)) ?? 0;
|
|
const isTag = n.type === "tag";
|
|
return {
|
|
...n,
|
|
degree: deg,
|
|
radius: isTag ? 7 : Math.max(6, Math.min(18, 6 + deg * 1.5)),
|
|
x: w / 2 + (Math.random() - 0.5) * 200,
|
|
y: h / 2 + (Math.random() - 0.5) * 200,
|
|
};
|
|
});
|
|
|
|
// Apply orphan filter (tag nodes always have edges so never filtered)
|
|
const visibleNodes = hideOrphans.value
|
|
? simNodes.filter((n) => (n.degree ?? 0) > 0)
|
|
: simNodes;
|
|
const visibleIds = new Set(visibleNodes.map((n) => String(n.id)));
|
|
|
|
const simEdges: SimulationLinkDatum<GraphNode>[] = edges.value
|
|
.filter((e) => {
|
|
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
|
|
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
|
|
return visibleIds.has(sid) && visibleIds.has(tid);
|
|
})
|
|
.map((e) => ({
|
|
source: String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source),
|
|
target: String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target),
|
|
type: (e as GraphEdge).type,
|
|
})) as SimulationLinkDatum<GraphNode>[] & { type?: string }[];
|
|
|
|
// Defs — arrowhead marker
|
|
const defs = svg.append("defs");
|
|
defs.append("marker")
|
|
.attr("id", "arrow")
|
|
.attr("viewBox", "0 -4 8 8")
|
|
.attr("refX", 8)
|
|
.attr("refY", 0)
|
|
.attr("markerWidth", 6)
|
|
.attr("markerHeight", 6)
|
|
.attr("orient", "auto")
|
|
.append("path")
|
|
.attr("d", "M0,-4L8,0L0,4")
|
|
.attr("fill", "var(--color-primary)")
|
|
.attr("opacity", "0.6");
|
|
|
|
// Zoom layer
|
|
const zoomG = svg.append("g").attr("class", "zoom-layer");
|
|
|
|
const zoomBehavior = zoom<SVGSVGElement, unknown>()
|
|
.scaleExtent([0.1, 8])
|
|
.on("zoom", (event) => {
|
|
zoomG.attr("transform", event.transform);
|
|
});
|
|
|
|
svg
|
|
.attr("width", w)
|
|
.attr("height", h)
|
|
.call(zoomBehavior)
|
|
.on("dblclick.zoom", null);
|
|
|
|
// Reset zoom to fit on initial load
|
|
svg.call(zoomBehavior.transform, zoomIdentity.translate(0, 0).scale(1));
|
|
|
|
// Edges
|
|
const linkSel = zoomG
|
|
.selectAll("line.graph-edge")
|
|
.data(simEdges)
|
|
.enter()
|
|
.append("line")
|
|
.attr("class", "graph-edge")
|
|
.attr("stroke", (d: any) =>
|
|
d.type === "wikilink" ? "var(--color-primary)" : "var(--color-text-muted)"
|
|
)
|
|
.attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
|
|
.attr("stroke-dasharray", null)
|
|
.attr("stroke-width", (d: any) => (d.type === "wikilink" ? 1.5 : 1))
|
|
.attr("marker-end", (d: any) => (d.type === "wikilink" ? "url(#arrow)" : null));
|
|
|
|
// Node groups
|
|
const nodeSel = zoomG
|
|
.selectAll("g.graph-node")
|
|
.data(visibleNodes)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", "graph-node")
|
|
.style("cursor", "pointer")
|
|
.on("click", (_event: MouseEvent, d: GraphNode) => {
|
|
if (d.type === "tag") {
|
|
router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
|
|
} else {
|
|
openPeek(d);
|
|
}
|
|
})
|
|
.on("mouseover", function(event: MouseEvent, d: GraphNode) {
|
|
const rect = containerRef.value!.getBoundingClientRect();
|
|
tooltip.value = {
|
|
visible: true,
|
|
x: event.clientX - rect.left + 12,
|
|
y: event.clientY - rect.top - 8,
|
|
node: d,
|
|
};
|
|
select(this as SVGGElement).select("circle")
|
|
.transition().duration(150)
|
|
.attr("r", (d.radius ?? 8) * 1.3);
|
|
select(this as SVGGElement).select("text")
|
|
.transition().duration(150)
|
|
.style("font-weight", "600")
|
|
.style("font-size", "11px");
|
|
})
|
|
.on("mousemove", (event: MouseEvent) => {
|
|
const rect = containerRef.value!.getBoundingClientRect();
|
|
tooltip.value.x = event.clientX - rect.left + 12;
|
|
tooltip.value.y = event.clientY - rect.top - 8;
|
|
})
|
|
.on("mouseout", function(_event: MouseEvent, d: GraphNode) {
|
|
tooltip.value.visible = false;
|
|
select(this as SVGGElement).select("circle")
|
|
.transition().duration(150)
|
|
.attr("r", d.radius ?? 8);
|
|
select(this as SVGGElement).select("text")
|
|
.transition().duration(150)
|
|
.style("font-weight", null)
|
|
.style("font-size", null);
|
|
});
|
|
|
|
nodeSel
|
|
.append("circle")
|
|
.attr("r", (d: GraphNode) => d.radius ?? 8)
|
|
.attr("fill", (d: GraphNode) => {
|
|
if (d.type === "tag") return "color-mix(in srgb, var(--color-primary) 20%, transparent)";
|
|
return d.project_color ?? "var(--color-bg-secondary)";
|
|
})
|
|
.attr("stroke", (d: GraphNode) =>
|
|
d.type === "tag" ? "var(--color-primary)" : "var(--color-border)"
|
|
)
|
|
.attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
|
|
.attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
|
|
.attr("opacity", (d: GraphNode) =>
|
|
!hideOrphans.value && (d.degree ?? 0) === 0 ? 0.35 : 1
|
|
);
|
|
|
|
nodeSel
|
|
.append("text")
|
|
.text((d: GraphNode) => {
|
|
const label = d.type === "tag" ? `#${d.title}` : d.title;
|
|
return label.length > 20 ? label.slice(0, 20) + "…" : label;
|
|
})
|
|
.attr("text-anchor", "middle")
|
|
.attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
|
|
.attr("font-size", "10px")
|
|
.attr("fill", (d: GraphNode) =>
|
|
d.type === "tag" ? "var(--color-primary)" : "var(--color-text-secondary)"
|
|
)
|
|
.attr("pointer-events", "none");
|
|
|
|
// Drag
|
|
const dragBehavior = drag<SVGGElement, GraphNode>()
|
|
.on("start", (event, d) => {
|
|
if (!event.active) simulation?.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
})
|
|
.on("drag", (event, d) => {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
})
|
|
.on("end", (event, d) => {
|
|
if (!event.active) simulation?.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
});
|
|
|
|
nodeSel.call(dragBehavior as any);
|
|
|
|
// Invisible project hub nodes — one per project, attract all member nodes inward
|
|
const projectHubs = new Map<number, GraphNode>();
|
|
for (const n of visibleNodes) {
|
|
if (n.project_id !== null && !projectHubs.has(n.project_id)) {
|
|
projectHubs.set(n.project_id, {
|
|
id: `hub:${n.project_id}`,
|
|
title: "",
|
|
type: "tag" as any, // unused — hubs are never rendered
|
|
tags: [],
|
|
project_id: n.project_id,
|
|
project_color: null,
|
|
degree: 0,
|
|
radius: 0,
|
|
x: w / 2 + (Math.random() - 0.5) * 100,
|
|
y: h / 2 + (Math.random() - 0.5) * 100,
|
|
});
|
|
}
|
|
}
|
|
|
|
const hubNodes = [...projectHubs.values()];
|
|
const allSimNodes = [...visibleNodes, ...hubNodes];
|
|
const hubEdges: SimulationLinkDatum<GraphNode>[] = [];
|
|
for (const n of visibleNodes) {
|
|
if (n.project_id !== null && projectHubs.has(n.project_id)) {
|
|
hubEdges.push({
|
|
source: String(n.id),
|
|
target: `hub:${n.project_id}`,
|
|
type: "hub",
|
|
} as any);
|
|
}
|
|
}
|
|
const allSimEdges = [...simEdges, ...hubEdges];
|
|
|
|
// Simulation
|
|
simulation = forceSimulation<GraphNode>(allSimNodes)
|
|
.force(
|
|
"link",
|
|
forceLink<GraphNode, SimulationLinkDatum<GraphNode>>(allSimEdges)
|
|
.id((d) => String(d.id))
|
|
.distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
|
|
.strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value)
|
|
)
|
|
.force("charge", forceManyBody<GraphNode>().strength((d: any) =>
|
|
String(d.id).startsWith("hub:") ? 0 : -repulsion.value
|
|
))
|
|
.force("gravityX", forceX<GraphNode>(w / 2).strength(gravity.value))
|
|
.force("gravityY", forceY<GraphNode>(h / 2).strength(gravity.value))
|
|
.force("collide", forceCollide<GraphNode>().radius((d) => (d.radius ?? 8) + 4))
|
|
.on("tick", () => {
|
|
linkSel
|
|
.attr("x1", (d: any) => (d.source as GraphNode).x ?? 0)
|
|
.attr("y1", (d: any) => (d.source as GraphNode).y ?? 0)
|
|
.attr("x2", (d: any) => {
|
|
const s = d.source as GraphNode;
|
|
const t = d.target as GraphNode;
|
|
const dx = (t.x ?? 0) - (s.x ?? 0);
|
|
const dy = (t.y ?? 0) - (s.y ?? 0);
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const r = (t.radius ?? 8) + 6;
|
|
return (t.x ?? 0) - (dx / dist) * r;
|
|
})
|
|
.attr("y2", (d: any) => {
|
|
const s = d.source as GraphNode;
|
|
const t = d.target as GraphNode;
|
|
const dx = (t.x ?? 0) - (s.x ?? 0);
|
|
const dy = (t.y ?? 0) - (s.y ?? 0);
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const r = (t.radius ?? 8) + 6;
|
|
return (t.y ?? 0) - (dy / dist) * r;
|
|
});
|
|
|
|
nodeSel.attr("transform", (d: GraphNode) => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
|
});
|
|
}
|
|
|
|
function applyPhysics() {
|
|
if (!simulation) return;
|
|
const r = repulsion.value;
|
|
(simulation.force("charge") as any)?.strength((d: any) =>
|
|
String(d.id).startsWith("hub:") ? 0 : -r
|
|
);
|
|
const lf = simulation.force("link") as any;
|
|
if (lf) {
|
|
lf.distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
|
|
.strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value);
|
|
}
|
|
(simulation.force("gravityX") as any)?.strength(gravity.value);
|
|
(simulation.force("gravityY") as any)?.strength(gravity.value);
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
|
|
watch([nodes, edges, hideOrphans], () => {
|
|
initGraph();
|
|
});
|
|
|
|
watch([repulsion, linkDist, linkStrength, hubPull, gravity], () => {
|
|
applyPhysics();
|
|
});
|
|
|
|
watch([projectFilter, showSharedTags], () => {
|
|
fetchData();
|
|
});
|
|
|
|
function handleResize() {
|
|
initGraph();
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([fetchData(), fetchProjects()]);
|
|
initGraph();
|
|
|
|
resizeObserver = new ResizeObserver(handleResize);
|
|
if (containerRef.value) resizeObserver.observe(containerRef.value);
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
simulation?.stop();
|
|
resizeObserver?.disconnect();
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<main class="graph-page">
|
|
<div class="graph-controls">
|
|
<select
|
|
v-model="projectFilter"
|
|
class="graph-select"
|
|
aria-label="Filter by project"
|
|
>
|
|
<option :value="null">All projects</option>
|
|
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title }}</option>
|
|
</select>
|
|
|
|
<label class="graph-toggle">
|
|
<input type="checkbox" v-model="showSharedTags" />
|
|
Show tags
|
|
</label>
|
|
|
|
<label class="graph-toggle">
|
|
<input type="checkbox" v-model="hideOrphans" />
|
|
Hide orphans
|
|
</label>
|
|
|
|
<button class="graph-physics-btn" :class="{ active: showPhysics }" @click="showPhysics = !showPhysics">
|
|
Physics
|
|
</button>
|
|
|
|
<span v-if="statsText" class="graph-stats">{{ statsText }}</span>
|
|
</div>
|
|
|
|
<div v-if="showPhysics" class="graph-physics-panel">
|
|
<label class="physics-knob">
|
|
<span class="knob-label">Repulsion <em>{{ repulsion }}</em></span>
|
|
<input type="range" v-model.number="repulsion" min="10" max="400" step="5" />
|
|
</label>
|
|
<label class="physics-knob">
|
|
<span class="knob-label">Link distance <em>{{ linkDist }}</em></span>
|
|
<input type="range" v-model.number="linkDist" min="20" max="200" step="5" />
|
|
</label>
|
|
<label class="physics-knob">
|
|
<span class="knob-label">Link strength <em>{{ linkStrength.toFixed(2) }}</em></span>
|
|
<input type="range" v-model.number="linkStrength" min="0.05" max="1" step="0.05" />
|
|
</label>
|
|
<label class="physics-knob">
|
|
<span class="knob-label">Project pull <em>{{ hubPull.toFixed(2) }}</em></span>
|
|
<input type="range" v-model.number="hubPull" min="0" max="1" step="0.05" />
|
|
</label>
|
|
<label class="physics-knob">
|
|
<span class="knob-label">Gravity <em>{{ gravity.toFixed(2) }}</em></span>
|
|
<input type="range" v-model.number="gravity" min="0" max="0.2" step="0.01" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="graph-wrap" ref="containerRef">
|
|
<svg ref="svgRef" class="graph-svg" />
|
|
|
|
<div v-if="loading" class="graph-overlay graph-loading">
|
|
<span class="spinner"></span>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="graph-overlay graph-empty">
|
|
<p>{{ error }}</p>
|
|
</div>
|
|
|
|
<div v-else-if="!loading && nodes.length === 0" class="graph-overlay graph-empty">
|
|
<p>No notes to display. Create some notes to see the graph.</p>
|
|
</div>
|
|
|
|
<!-- Peek panel -->
|
|
<Transition name="peek-slide">
|
|
<div v-if="peekNode" class="graph-peek-panel">
|
|
<div class="peek-header">
|
|
<span :class="['peek-type-badge', `peek-type-${peekNode.type}`]">{{ peekNode.type }}</span>
|
|
<div class="peek-actions">
|
|
<router-link
|
|
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}` : `/notes/${peekNode.id}`"
|
|
class="peek-link"
|
|
>Open</router-link>
|
|
<router-link
|
|
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
|
|
class="peek-link"
|
|
>Edit</router-link>
|
|
<button class="peek-close" @click="closePeek" title="Close (Esc)">✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="peek-title">{{ peekNode.title }}</h2>
|
|
|
|
<div v-if="peekNode.tags?.length" class="peek-tags">
|
|
<span v-for="tag in peekNode.tags" :key="tag" class="tag-chip">#{{ tag }}</span>
|
|
</div>
|
|
|
|
<div class="peek-body-wrap">
|
|
<div v-if="peekLoading" class="peek-loading">Loading...</div>
|
|
<div
|
|
v-else-if="peekBody"
|
|
class="prose peek-prose"
|
|
v-html="renderMarkdown(peekBody)"
|
|
/>
|
|
<div v-else class="peek-empty">No content.</div>
|
|
</div>
|
|
|
|
<div v-if="peekLinkedNodes.length" class="peek-linked">
|
|
<div class="peek-linked-label">Linked notes</div>
|
|
<ul class="peek-linked-list">
|
|
<li
|
|
v-for="n in peekLinkedNodes"
|
|
:key="n.id"
|
|
class="peek-linked-item"
|
|
@click="openPeek(n)"
|
|
>
|
|
<span :class="['peek-linked-type', `peek-type-${n.type}`]">{{ n.type[0].toUpperCase() }}</span>
|
|
{{ n.title }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<div
|
|
v-if="tooltip.visible && tooltip.node"
|
|
class="graph-tooltip"
|
|
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
|
|
>
|
|
<div class="tooltip-title">{{ tooltip.node.type === 'tag' ? '#' + tooltip.node.title : tooltip.node.title }}</div>
|
|
<div v-if="tooltip.node.tags.length" class="tooltip-tags">
|
|
<span v-for="tag in tooltip.node.tags" :key="tag" class="tag-chip">{{ tag }}</span>
|
|
</div>
|
|
<div class="tooltip-meta">{{ tooltip.node.type === 'tag' ? 'tag' : tooltip.node.type }}</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.graph-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - var(--header-height, 52px));
|
|
overflow: hidden;
|
|
}
|
|
|
|
.graph-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.6rem 1rem;
|
|
background: var(--color-bg-secondary);
|
|
border-bottom: 1px solid var(--color-border);
|
|
flex-shrink: 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.graph-select {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text);
|
|
font-size: 0.875rem;
|
|
padding: 0.3rem 0.6rem;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.graph-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
font-size: 0.875rem;
|
|
color: var(--color-text-secondary);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.graph-toggle input {
|
|
cursor: pointer;
|
|
accent-color: var(--color-primary);
|
|
}
|
|
|
|
.graph-stats {
|
|
margin-left: auto;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.graph-physics-btn {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
padding: 0.3rem 0.7rem;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
.graph-physics-btn:hover {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-text);
|
|
}
|
|
.graph-physics-btn.active {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.graph-physics-panel {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem 1.5rem;
|
|
padding: 0.6rem 1rem;
|
|
background: var(--color-bg-secondary);
|
|
border-bottom: 1px solid var(--color-border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.physics-knob {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
min-width: 140px;
|
|
flex: 1;
|
|
max-width: 220px;
|
|
}
|
|
|
|
.knob-label {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-secondary);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.knob-label em {
|
|
font-style: normal;
|
|
color: var(--color-text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.physics-knob input[type="range"] {
|
|
width: 100%;
|
|
accent-color: var(--color-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.graph-wrap {
|
|
flex: 1;
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
.graph-svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.graph-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.graph-empty p {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.spinner {
|
|
width: 28px;
|
|
height: 28px;
|
|
border: 3px solid var(--color-border);
|
|
border-top-color: var(--color-primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.7s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.graph-tooltip {
|
|
position: absolute;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15));
|
|
padding: 0.5rem 0.75rem;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
max-width: 240px;
|
|
}
|
|
|
|
.tooltip-title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.tooltip-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.tag-chip {
|
|
font-size: 0.7rem;
|
|
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
|
color: var(--color-primary);
|
|
border-radius: 999px;
|
|
padding: 0.1rem 0.4rem;
|
|
}
|
|
|
|
.tooltip-meta {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
/* ── Peek panel ── */
|
|
.graph-peek-panel {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 340px;
|
|
background: var(--color-bg-card);
|
|
border-left: 1px solid var(--color-border);
|
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
z-index: 20;
|
|
}
|
|
|
|
.peek-slide-enter-active,
|
|
.peek-slide-leave-active {
|
|
transition: transform 0.2s ease;
|
|
}
|
|
.peek-slide-enter-from,
|
|
.peek-slide-leave-to {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
.peek-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.peek-type-badge {
|
|
font-size: 0.68rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.15rem 0.45rem;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-text-muted);
|
|
}
|
|
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
|
|
|
|
.peek-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.peek-link {
|
|
font-size: 0.78rem;
|
|
color: var(--color-primary);
|
|
text-decoration: none;
|
|
}
|
|
.peek-link:hover { text-decoration: underline; }
|
|
|
|
.peek-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
padding: 0.1rem 0.25rem;
|
|
border-radius: 3px;
|
|
}
|
|
.peek-close:hover { color: var(--color-text); }
|
|
|
|
.peek-title {
|
|
margin: 0;
|
|
padding: 0.75rem 0.75rem 0.4rem;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.peek-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
padding: 0 0.75rem 0.5rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.peek-body-wrap {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0 0.75rem 0.5rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.peek-prose {
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.peek-loading,
|
|
.peek-empty {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-muted);
|
|
padding-top: 0.5rem;
|
|
}
|
|
|
|
.peek-linked {
|
|
flex-shrink: 0;
|
|
padding: 0.5rem 0.75rem 0.75rem;
|
|
}
|
|
|
|
.peek-linked-label {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
|
|
.peek-linked-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
max-height: 160px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.peek-linked-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.82rem;
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
padding: 0.25rem 0.4rem;
|
|
border-radius: 4px;
|
|
}
|
|
.peek-linked-item:hover {
|
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.peek-linked-type {
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
width: 1.1rem;
|
|
height: 1.1rem;
|
|
border-radius: 50%;
|
|
background: var(--color-bg);
|
|
border: 1px solid var(--color-border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
color: var(--color-text-muted);
|
|
}
|
|
</style>
|