mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-11 22:51:05 +00:00
clean up unused folders and files (#903)
This commit is contained in:
parent
253d16b4b8
commit
7d762806a9
16 changed files with 0 additions and 4680 deletions
|
|
@ -1,100 +0,0 @@
|
|||
// Enhanced glass-morphism color palette
|
||||
export const colors = {
|
||||
background: {
|
||||
primary: "#0f1419", // Deep dark blue-gray
|
||||
secondary: "#1a1f29", // Slightly lighter
|
||||
accent: "#252a35", // Card backgrounds
|
||||
},
|
||||
document: {
|
||||
primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white
|
||||
secondary: "rgba(255, 255, 255, 0.12)", // More visible
|
||||
accent: "rgba(255, 255, 255, 0.18)", // Hover state
|
||||
border: "rgba(255, 255, 255, 0.25)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction
|
||||
},
|
||||
memory: {
|
||||
primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue
|
||||
secondary: "rgba(147, 197, 253, 0.16)", // More visible
|
||||
accent: "rgba(147, 197, 253, 0.24)", // Hover state
|
||||
border: "rgba(147, 197, 253, 0.35)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction
|
||||
},
|
||||
connection: {
|
||||
weak: "rgba(148, 163, 184, 0)", // Very subtle
|
||||
memory: "rgba(148, 163, 184, 0.3)", // Very subtle
|
||||
medium: "rgba(148, 163, 184, 0.125)", // Medium visibility
|
||||
strong: "rgba(148, 163, 184, 0.4)", // Strong connection
|
||||
},
|
||||
text: {
|
||||
primary: "#ffffff", // Pure white
|
||||
secondary: "#e2e8f0", // Light gray
|
||||
muted: "#94a3b8", // Medium gray
|
||||
},
|
||||
accent: {
|
||||
primary: "rgba(59, 130, 246, 0.7)", // Clean blue
|
||||
secondary: "rgba(99, 102, 241, 0.6)", // Clean purple
|
||||
glow: "rgba(147, 197, 253, 0.6)", // Subtle glow
|
||||
amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring
|
||||
emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new
|
||||
},
|
||||
status: {
|
||||
forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten
|
||||
expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon
|
||||
new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories
|
||||
},
|
||||
relations: {
|
||||
updates: "rgba(147, 77, 253, 0.5)", // purple
|
||||
extends: "rgba(16, 185, 129, 0.5)", // green
|
||||
derives: "rgba(147, 197, 253, 0.5)", // blue
|
||||
},
|
||||
};
|
||||
|
||||
export const LAYOUT_CONSTANTS = {
|
||||
centerX: 400,
|
||||
centerY: 300,
|
||||
clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble
|
||||
spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out
|
||||
documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out
|
||||
minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius
|
||||
memoryClusterRadius: 300,
|
||||
};
|
||||
|
||||
// Graph view settings
|
||||
export const GRAPH_SETTINGS = {
|
||||
console: {
|
||||
initialZoom: 0.8, // Higher zoom for console - better overview
|
||||
initialPanX: 0,
|
||||
initialPanY: 0,
|
||||
},
|
||||
consumer: {
|
||||
initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
|
||||
initialPanX: 400, // Pan towards center to compensate for larger layout
|
||||
initialPanY: 300, // Pan towards center to compensate for larger layout
|
||||
},
|
||||
};
|
||||
|
||||
// Responsive positioning for different app variants
|
||||
export const POSITIONING = {
|
||||
console: {
|
||||
legend: {
|
||||
desktop: "bottom-4 right-4",
|
||||
mobile: "bottom-4 right-4",
|
||||
},
|
||||
loadingIndicator: "top-20 right-4",
|
||||
|
||||
spacesSelector: "top-4 left-4",
|
||||
viewToggle: "", // Not used in console
|
||||
nodeDetail: "top-4 right-4",
|
||||
},
|
||||
consumer: {
|
||||
legend: {
|
||||
desktop: "top-18 right-4",
|
||||
mobile: "bottom-[180px] left-4",
|
||||
},
|
||||
loadingIndicator: "top-20 right-4",
|
||||
|
||||
spacesSelector: "", // Hidden in consumer
|
||||
viewToggle: "top-4 right-4", // Consumer has view toggle
|
||||
nodeDetail: "top-4 right-4",
|
||||
},
|
||||
};
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@lib/utils";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { GlassMenuEffect } from "@ui/other/glass-effect";
|
||||
import { Move, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import type { ControlsProps } from "./types";
|
||||
|
||||
export const Controls = memo<ControlsProps>(
|
||||
({ onZoomIn, onZoomOut, onResetView, variant = "console" }) => {
|
||||
// Use explicit classes - controls positioning not defined in constants
|
||||
// Using a reasonable default position
|
||||
const getPositioningClasses = () => {
|
||||
if (variant === "console") {
|
||||
return "bottom-4 left-4";
|
||||
}
|
||||
if (variant === "consumer") {
|
||||
return "bottom-20 right-4";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 rounded-xl overflow-hidden",
|
||||
getPositioningClasses(),
|
||||
)}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<div className="relative z-10 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
|
||||
onClick={onZoomIn}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
|
||||
onClick={onZoomOut}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
|
||||
onClick={onResetView}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Move className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Controls.displayName = "Controls";
|
||||
|
|
@ -1,762 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { colors } from "./constants";
|
||||
import type {
|
||||
DocumentWithMemories,
|
||||
GraphCanvasProps,
|
||||
GraphNode,
|
||||
MemoryEntry,
|
||||
} from "./types";
|
||||
|
||||
export const GraphCanvas = memo<GraphCanvasProps>(
|
||||
({
|
||||
nodes,
|
||||
edges,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
width,
|
||||
height,
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragMove,
|
||||
onNodeDragEnd,
|
||||
onPanStart,
|
||||
onPanMove,
|
||||
onPanEnd,
|
||||
onWheel,
|
||||
onDoubleClick,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
draggingNodeId,
|
||||
highlightDocumentIds,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const currentHoveredNode = useRef<string | null>(null);
|
||||
|
||||
// Initialize start time once
|
||||
useEffect(() => {
|
||||
startTimeRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
// Efficient hit detection
|
||||
const getNodeAtPosition = useCallback(
|
||||
(x: number, y: number): string | null => {
|
||||
// Check from top-most to bottom-most: memory nodes are drawn after documents
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const node = nodes[i]!;
|
||||
const screenX = node.x * zoom + panX;
|
||||
const screenY = node.y * zoom + panY;
|
||||
const nodeSize = node.size * zoom;
|
||||
|
||||
const dx = x - screenX;
|
||||
const dy = y - screenY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance <= nodeSize / 2) {
|
||||
return node.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[nodes, panX, panY, zoom],
|
||||
);
|
||||
|
||||
// Handle mouse events
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
mousePos.current = { x, y };
|
||||
|
||||
const nodeId = getNodeAtPosition(x, y);
|
||||
if (nodeId !== currentHoveredNode.current) {
|
||||
currentHoveredNode.current = nodeId;
|
||||
onNodeHover(nodeId);
|
||||
}
|
||||
|
||||
// Handle node dragging
|
||||
if (draggingNodeId) {
|
||||
onNodeDragMove(e);
|
||||
}
|
||||
},
|
||||
[getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const nodeId = getNodeAtPosition(x, y);
|
||||
if (nodeId) {
|
||||
// When starting a node drag, prevent initiating pan
|
||||
e.stopPropagation();
|
||||
onNodeDragStart(nodeId, e);
|
||||
return;
|
||||
}
|
||||
onPanStart(e);
|
||||
},
|
||||
[getNodeAtPosition, onNodeDragStart, onPanStart],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const nodeId = getNodeAtPosition(x, y);
|
||||
if (nodeId) {
|
||||
onNodeClick(nodeId);
|
||||
}
|
||||
},
|
||||
[getNodeAtPosition, onNodeClick],
|
||||
);
|
||||
|
||||
// Professional rendering function with LOD
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
const _elapsed = currentTime - startTimeRef.current;
|
||||
|
||||
// Level-of-detail optimization based on zoom
|
||||
const useSimplifiedRendering = zoom < 0.3;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Set high quality rendering
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
// Draw minimal background grid
|
||||
ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid
|
||||
ctx.lineWidth = 1;
|
||||
const gridSpacing = 100 * zoom;
|
||||
const offsetX = panX % gridSpacing;
|
||||
const offsetY = panY % gridSpacing;
|
||||
|
||||
// Simple, clean grid lines
|
||||
for (let x = offsetX; x < width; x += gridSpacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = offsetY; y < height; y += gridSpacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Create node lookup map
|
||||
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
||||
|
||||
// Draw enhanced edges with sophisticated styling
|
||||
ctx.lineCap = "round";
|
||||
edges.forEach((edge) => {
|
||||
const sourceNode = nodeMap.get(edge.source);
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const sourceX = sourceNode.x * zoom + panX;
|
||||
const sourceY = sourceNode.y * zoom + panY;
|
||||
const targetX = targetNode.x * zoom + panX;
|
||||
const targetY = targetNode.y * zoom + panY;
|
||||
|
||||
// Enhanced viewport culling with edge type considerations
|
||||
if (
|
||||
sourceX < -100 ||
|
||||
sourceX > width + 100 ||
|
||||
targetX < -100 ||
|
||||
targetX > width + 100
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip very weak connections when zoomed out for performance
|
||||
if (useSimplifiedRendering) {
|
||||
if (
|
||||
edge.edgeType === "doc-memory" &&
|
||||
edge.visualProps.opacity < 0.3
|
||||
) {
|
||||
return; // Skip very weak doc-memory edges when zoomed out
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced connection styling based on edge type
|
||||
let connectionColor = colors.connection.weak;
|
||||
let dashPattern: number[] = [];
|
||||
let opacity = edge.visualProps.opacity;
|
||||
let lineWidth = Math.max(1, edge.visualProps.thickness * zoom);
|
||||
|
||||
if (edge.edgeType === "doc-memory") {
|
||||
// Doc-memory: Solid thin lines, subtle
|
||||
dashPattern = [];
|
||||
connectionColor = colors.connection.memory;
|
||||
opacity = 0.9;
|
||||
lineWidth = 1;
|
||||
} else if (edge.edgeType === "doc-doc") {
|
||||
// Doc-doc: Thick dashed lines with strong similarity emphasis
|
||||
dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out
|
||||
opacity = Math.max(0, edge.similarity * 0.5);
|
||||
lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity
|
||||
|
||||
if (edge.similarity > 0.85)
|
||||
connectionColor = colors.connection.strong;
|
||||
else if (edge.similarity > 0.725)
|
||||
connectionColor = colors.connection.medium;
|
||||
} else if (edge.edgeType === "version") {
|
||||
// Version chains: Double line effect with relation-specific colors
|
||||
dashPattern = [];
|
||||
connectionColor = edge.color || colors.relations.updates;
|
||||
opacity = 0.8;
|
||||
lineWidth = 2;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = connectionColor;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.setLineDash(dashPattern);
|
||||
|
||||
if (edge.edgeType === "version") {
|
||||
// Special double-line rendering for version chains
|
||||
// First line (outer)
|
||||
ctx.lineWidth = 3;
|
||||
ctx.globalAlpha = opacity * 0.3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sourceX, sourceY);
|
||||
ctx.lineTo(targetX, targetY);
|
||||
ctx.stroke();
|
||||
|
||||
// Second line (inner)
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sourceX, sourceY);
|
||||
ctx.lineTo(targetX, targetY);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Simplified lines when zoomed out, curved when zoomed in
|
||||
if (useSimplifiedRendering) {
|
||||
// Straight lines for performance
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sourceX, sourceY);
|
||||
ctx.lineTo(targetX, targetY);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Regular curved line for doc-memory and doc-doc
|
||||
const midX = (sourceX + targetX) / 2;
|
||||
const midY = (sourceY + targetY) / 2;
|
||||
const dx = targetX - sourceX;
|
||||
const dy = targetY - sourceY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const controlOffset =
|
||||
edge.edgeType === "doc-memory"
|
||||
? 15
|
||||
: Math.min(30, distance * 0.2);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sourceX, sourceY);
|
||||
ctx.quadraticCurveTo(
|
||||
midX + controlOffset * (dy / distance),
|
||||
midY - controlOffset * (dx / distance),
|
||||
targetX,
|
||||
targetY,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle arrow head for version edges
|
||||
if (edge.edgeType === "version") {
|
||||
const angle = Math.atan2(targetY - sourceY, targetX - sourceX);
|
||||
const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle
|
||||
const arrowWidth = Math.max(8, 12 * zoom);
|
||||
|
||||
// Calculate arrow position offset from node edge
|
||||
const nodeRadius = (targetNode.size * zoom) / 2;
|
||||
const offsetDistance = nodeRadius + 2;
|
||||
const arrowX = targetX - Math.cos(angle) * offsetDistance;
|
||||
const arrowY = targetY - Math.sin(angle) * offsetDistance;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(arrowX, arrowY);
|
||||
ctx.rotate(angle);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Simple outlined arrow (not filled)
|
||||
ctx.strokeStyle = connectionColor;
|
||||
ctx.lineWidth = Math.max(1, 1.5 * zoom);
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(-arrowLength, arrowWidth / 2);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(-arrowLength, -arrowWidth / 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Prepare highlight set from provided document IDs (customId or internal)
|
||||
const highlightSet = new Set<string>(highlightDocumentIds ?? []);
|
||||
|
||||
// Draw nodes with enhanced styling and LOD optimization
|
||||
nodes.forEach((node) => {
|
||||
const screenX = node.x * zoom + panX;
|
||||
const screenY = node.y * zoom + panY;
|
||||
const nodeSize = node.size * zoom;
|
||||
|
||||
// Enhanced viewport culling
|
||||
const margin = nodeSize + 50;
|
||||
if (
|
||||
screenX < -margin ||
|
||||
screenX > width + margin ||
|
||||
screenY < -margin ||
|
||||
screenY > height + margin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isHovered = currentHoveredNode.current === node.id;
|
||||
const isDragging = node.isDragging;
|
||||
const isHighlightedDocument = (() => {
|
||||
if (node.type !== "document" || highlightSet.size === 0) return false;
|
||||
const doc = node.data as DocumentWithMemories;
|
||||
if (doc.customId && highlightSet.has(doc.customId)) return true;
|
||||
return highlightSet.has(doc.id);
|
||||
})();
|
||||
|
||||
if (node.type === "document") {
|
||||
// Enhanced glassmorphism document styling
|
||||
const docWidth = nodeSize * 1.4;
|
||||
const docHeight = nodeSize * 0.9;
|
||||
|
||||
// Multi-layer glass effect
|
||||
ctx.fillStyle = isDragging
|
||||
? colors.document.accent
|
||||
: isHovered
|
||||
? colors.document.secondary
|
||||
: colors.document.primary;
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Enhanced border with subtle glow
|
||||
ctx.strokeStyle = isDragging
|
||||
? colors.document.glow
|
||||
: isHovered
|
||||
? colors.document.accent
|
||||
: colors.document.border;
|
||||
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1;
|
||||
|
||||
// Rounded rectangle with enhanced styling
|
||||
const radius = useSimplifiedRendering ? 6 : 12;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
screenX - docWidth / 2,
|
||||
screenY - docHeight / 2,
|
||||
docWidth,
|
||||
docHeight,
|
||||
radius,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Subtle inner highlight for glass effect (skip when zoomed out)
|
||||
if (!useSimplifiedRendering && (isHovered || isDragging)) {
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
screenX - docWidth / 2 + 1,
|
||||
screenY - docHeight / 2 + 1,
|
||||
docWidth - 2,
|
||||
docHeight - 2,
|
||||
radius - 1,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Highlight ring for search hits
|
||||
if (isHighlightedDocument) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.strokeStyle = colors.accent.primary;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([6, 4]);
|
||||
const ringPadding = 10;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
screenX - docWidth / 2 - ringPadding,
|
||||
screenY - docHeight / 2 - ringPadding,
|
||||
docWidth + ringPadding * 2,
|
||||
docHeight + ringPadding * 2,
|
||||
radius + 6,
|
||||
);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
}
|
||||
} else {
|
||||
// Enhanced memory styling with status indicators
|
||||
const mem = node.data as MemoryEntry;
|
||||
const isForgotten =
|
||||
mem.isForgotten ||
|
||||
(mem.forgetAfter &&
|
||||
new Date(mem.forgetAfter).getTime() < Date.now());
|
||||
const isLatest = mem.isLatest;
|
||||
|
||||
// Check if memory is expiring soon (within 7 days)
|
||||
const expiringSoon =
|
||||
mem.forgetAfter &&
|
||||
!isForgotten &&
|
||||
new Date(mem.forgetAfter).getTime() - Date.now() <
|
||||
1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
// Check if memory is new (created within last 24 hours)
|
||||
const isNew =
|
||||
!isForgotten &&
|
||||
new Date(mem.createdAt).getTime() >
|
||||
Date.now() - 1000 * 60 * 60 * 24;
|
||||
|
||||
// Determine colors based on status
|
||||
let fillColor = colors.memory.primary;
|
||||
let borderColor = colors.memory.border;
|
||||
let glowColor = colors.memory.glow;
|
||||
|
||||
if (isForgotten) {
|
||||
fillColor = colors.status.forgotten;
|
||||
borderColor = "rgba(220,38,38,0.3)";
|
||||
glowColor = "rgba(220,38,38,0.2)";
|
||||
} else if (expiringSoon) {
|
||||
borderColor = colors.status.expiring;
|
||||
glowColor = colors.accent.amber;
|
||||
} else if (isNew) {
|
||||
borderColor = colors.status.new;
|
||||
glowColor = colors.accent.emerald;
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
fillColor = colors.memory.accent;
|
||||
borderColor = glowColor;
|
||||
} else if (isHovered) {
|
||||
fillColor = colors.memory.secondary;
|
||||
}
|
||||
|
||||
const radius = nodeSize / 2;
|
||||
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.globalAlpha = isLatest ? 1 : 0.4;
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5;
|
||||
|
||||
if (useSimplifiedRendering) {
|
||||
// Simple circles when zoomed out for performance
|
||||
ctx.beginPath();
|
||||
ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// HEXAGONAL memory nodes when zoomed in
|
||||
const sides = 6;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top
|
||||
const x = screenX + radius * Math.cos(angle);
|
||||
const y = screenY + radius * Math.sin(angle);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Inner highlight for glass effect
|
||||
if (isHovered || isDragging) {
|
||||
ctx.strokeStyle = "rgba(147, 197, 253, 0.3)";
|
||||
ctx.lineWidth = 1;
|
||||
const innerRadius = radius - 2;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
|
||||
const x = screenX + innerRadius * Math.cos(angle);
|
||||
const y = screenY + innerRadius * Math.sin(angle);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Status indicators overlay (always preserve these as required)
|
||||
if (isForgotten) {
|
||||
// Cross for forgotten memories
|
||||
ctx.strokeStyle = "rgba(220,38,38,0.4)";
|
||||
ctx.lineWidth = 2;
|
||||
const r = nodeSize * 0.25;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(screenX - r, screenY - r);
|
||||
ctx.lineTo(screenX + r, screenY + r);
|
||||
ctx.moveTo(screenX + r, screenY - r);
|
||||
ctx.lineTo(screenX - r, screenY + r);
|
||||
ctx.stroke();
|
||||
} else if (isNew) {
|
||||
// Small dot for new memories
|
||||
ctx.fillStyle = colors.status.new;
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
screenX + nodeSize * 0.25,
|
||||
screenY - nodeSize * 0.25,
|
||||
Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced hover glow effect (skip when zoomed out for performance)
|
||||
if (!useSimplifiedRendering && (isHovered || isDragging)) {
|
||||
const glowColor =
|
||||
node.type === "document"
|
||||
? colors.document.glow
|
||||
: colors.memory.glow;
|
||||
|
||||
ctx.strokeStyle = glowColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.globalAlpha = 0.6;
|
||||
|
||||
ctx.beginPath();
|
||||
const glowSize = nodeSize * 0.7;
|
||||
if (node.type === "document") {
|
||||
ctx.roundRect(
|
||||
screenX - glowSize,
|
||||
screenY - glowSize / 1.4,
|
||||
glowSize * 2,
|
||||
glowSize * 1.4,
|
||||
15,
|
||||
);
|
||||
} else {
|
||||
// Hexagonal glow for memory nodes
|
||||
const glowRadius = glowSize;
|
||||
const sides = 6;
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
|
||||
const x = screenX + glowRadius * Math.cos(angle);
|
||||
const y = screenY + glowRadius * Math.sin(angle);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]);
|
||||
|
||||
// Change-based rendering instead of continuous animation
|
||||
const lastRenderParams = useRef<string>("");
|
||||
|
||||
// Create a render key that changes when visual state changes
|
||||
const renderKey = useMemo(() => {
|
||||
const nodePositions = nodes
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`,
|
||||
)
|
||||
.join("|");
|
||||
const highlightKey = (highlightDocumentIds ?? []).join("|");
|
||||
return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`;
|
||||
}, [
|
||||
nodes,
|
||||
edges.length,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
width,
|
||||
height,
|
||||
highlightDocumentIds,
|
||||
]);
|
||||
|
||||
// Only render when something actually changed
|
||||
useEffect(() => {
|
||||
if (renderKey !== lastRenderParams.current) {
|
||||
lastRenderParams.current = renderKey;
|
||||
render();
|
||||
}
|
||||
}, [renderKey, render]);
|
||||
|
||||
// Cleanup any existing animation frames
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add native wheel event listener to prevent browser zoom
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const handleNativeWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Call the onWheel handler with a synthetic-like event
|
||||
onWheel({
|
||||
deltaY: e.deltaY,
|
||||
deltaX: e.deltaX,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
currentTarget: canvas,
|
||||
nativeEvent: e,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.WheelEvent);
|
||||
};
|
||||
|
||||
// Add listener with passive: false to ensure preventDefault works
|
||||
canvas.addEventListener("wheel", handleNativeWheel, { passive: false });
|
||||
|
||||
// Also prevent gesture events for touch devices
|
||||
const handleGesture = (e: Event) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
canvas.addEventListener("gesturestart", handleGesture, {
|
||||
passive: false,
|
||||
});
|
||||
canvas.addEventListener("gesturechange", handleGesture, {
|
||||
passive: false,
|
||||
});
|
||||
canvas.addEventListener("gestureend", handleGesture, { passive: false });
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener("wheel", handleNativeWheel);
|
||||
canvas.removeEventListener("gesturestart", handleGesture);
|
||||
canvas.removeEventListener("gesturechange", handleGesture);
|
||||
canvas.removeEventListener("gestureend", handleGesture);
|
||||
};
|
||||
}, [onWheel]);
|
||||
|
||||
// High-DPI handling --------------------------------------------------
|
||||
const dpr =
|
||||
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// upscale backing store
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.scale(dpr, dpr);
|
||||
}, [width, height, dpr]);
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<canvas
|
||||
className="absolute inset-0"
|
||||
height={height}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseLeave={() => {
|
||||
if (draggingNodeId) {
|
||||
onNodeDragEnd();
|
||||
} else {
|
||||
onPanEnd();
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
handleMouseMove(e);
|
||||
if (!draggingNodeId) {
|
||||
onPanMove(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (draggingNodeId) {
|
||||
onNodeDragEnd();
|
||||
} else {
|
||||
onPanEnd();
|
||||
}
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
cursor: draggingNodeId
|
||||
? "grabbing"
|
||||
: currentHoveredNode.current
|
||||
? "grab"
|
||||
: "move",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
}}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
GraphCanvas.displayName = "GraphCanvas";
|
||||
|
|
@ -1,794 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Application, extend } from "@pixi/react";
|
||||
import { Container as PixiContainer, Graphics as PixiGraphics } from "pixi.js";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { colors } from "./constants";
|
||||
import type { GraphCanvasProps, MemoryEntry } from "./types";
|
||||
|
||||
// Register Pixi Graphics and Container so they can be used as JSX elements
|
||||
extend({ Graphics: PixiGraphics, Container: PixiContainer });
|
||||
|
||||
export const GraphWebGLCanvas = memo<GraphCanvasProps>(
|
||||
({
|
||||
nodes,
|
||||
edges,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
width,
|
||||
height,
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragMove,
|
||||
onNodeDragEnd,
|
||||
onPanStart,
|
||||
onPanMove,
|
||||
onPanEnd,
|
||||
onWheel,
|
||||
onDoubleClick,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
draggingNodeId,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isPanningRef = useRef(false);
|
||||
const currentHoveredRef = useRef<string | null>(null);
|
||||
const pointerDownPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerMovedRef = useRef(false);
|
||||
// World container that is transformed instead of redrawing every pan/zoom
|
||||
const worldContainerRef = useRef<PixiContainer | null>(null);
|
||||
|
||||
// Throttled wheel handling -------------------------------------------
|
||||
const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
});
|
||||
const wheelRafRef = useRef<number | null>(null);
|
||||
// Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance
|
||||
|
||||
// Persistent graphics refs
|
||||
const gridG = useRef<PixiGraphics | null>(null);
|
||||
const edgesG = useRef<PixiGraphics | null>(null);
|
||||
const docsG = useRef<PixiGraphics | null>(null);
|
||||
const memsG = useRef<PixiGraphics | null>(null);
|
||||
|
||||
// ---------- Zoom bucket (reduces redraw frequency) ----------
|
||||
const zoomBucket = useMemo(() => Math.round(zoom * 4) / 4, [zoom]);
|
||||
|
||||
// Redraw layers only when their data changes ----------------------
|
||||
useEffect(() => {
|
||||
if (gridG.current) drawGrid(gridG.current);
|
||||
}, [panX, panY, zoom, width, height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (edgesG.current) drawEdges(edgesG.current);
|
||||
}, [edgesG.current, edges, nodes, zoomBucket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (docsG.current) drawDocuments(docsG.current);
|
||||
}, [docsG.current, nodes, zoomBucket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (memsG.current) drawMemories(memsG.current);
|
||||
}, [memsG.current, nodes, zoomBucket]);
|
||||
|
||||
// Apply pan & zoom via world transform instead of geometry rebuilds
|
||||
useEffect(() => {
|
||||
if (worldContainerRef.current) {
|
||||
worldContainerRef.current.position.set(panX, panY);
|
||||
worldContainerRef.current.scale.set(zoom);
|
||||
}
|
||||
}, [panX, panY, zoom]);
|
||||
|
||||
// No bitmap caching – nothing to clean up
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
const getNodeAtPosition = useCallback(
|
||||
(clientX: number, clientY: number): string | null => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
|
||||
const localX = clientX - rect.left;
|
||||
const localY = clientY - rect.top;
|
||||
|
||||
const worldX = (localX - panX) / zoom;
|
||||
const worldY = (localY - panY) / zoom;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === "document") {
|
||||
const halfW = (node.size * 1.4) / 2;
|
||||
const halfH = (node.size * 0.9) / 2;
|
||||
if (
|
||||
worldX >= node.x - halfW &&
|
||||
worldX <= node.x + halfW &&
|
||||
worldY >= node.y - halfH &&
|
||||
worldY <= node.y + halfH
|
||||
) {
|
||||
return node.id;
|
||||
}
|
||||
} else if (node.type === "memory") {
|
||||
const r = node.size / 2;
|
||||
const dx = worldX - node.x;
|
||||
const dy = worldY - node.y;
|
||||
if (dx * dx + dy * dy <= r * r) {
|
||||
return node.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[nodes, panX, panY, zoom],
|
||||
);
|
||||
|
||||
/* ---------- Grid drawing ---------- */
|
||||
const drawGrid = useCallback(
|
||||
(g: PixiGraphics) => {
|
||||
g.clear();
|
||||
|
||||
const gridColor = 0x94a3b8; // rgb(148,163,184)
|
||||
const gridAlpha = 0.03;
|
||||
const gridSpacing = 100 * zoom;
|
||||
|
||||
// panning offsets
|
||||
const offsetX = panX % gridSpacing;
|
||||
const offsetY = panY % gridSpacing;
|
||||
|
||||
g.lineStyle(1, gridColor, gridAlpha);
|
||||
|
||||
// vertical lines
|
||||
for (let x = offsetX; x < width; x += gridSpacing) {
|
||||
g.moveTo(x, 0);
|
||||
g.lineTo(x, height);
|
||||
}
|
||||
|
||||
// horizontal lines
|
||||
for (let y = offsetY; y < height; y += gridSpacing) {
|
||||
g.moveTo(0, y);
|
||||
g.lineTo(width, y);
|
||||
}
|
||||
|
||||
// Stroke to render grid lines
|
||||
g.stroke();
|
||||
},
|
||||
[panX, panY, zoom, width, height],
|
||||
);
|
||||
|
||||
/* ---------- Color parsing ---------- */
|
||||
const toHexAlpha = (input: string): { hex: number; alpha: number } => {
|
||||
if (!input) return { hex: 0xffffff, alpha: 1 };
|
||||
const str = input.trim().toLowerCase();
|
||||
// rgba() or rgb()
|
||||
const rgbaMatch = str
|
||||
.replace(/\s+/g, "")
|
||||
.match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\)/i);
|
||||
if (rgbaMatch) {
|
||||
const r = Number.parseInt(rgbaMatch[1] || "0");
|
||||
const g = Number.parseInt(rgbaMatch[2] || "0");
|
||||
const b = Number.parseInt(rgbaMatch[3] || "0");
|
||||
const a =
|
||||
rgbaMatch[4] !== undefined ? Number.parseFloat(rgbaMatch[4]) : 1;
|
||||
return { hex: (r << 16) + (g << 8) + b, alpha: a };
|
||||
}
|
||||
// #rrggbb or #rrggbbaa
|
||||
if (str.startsWith("#")) {
|
||||
const hexBody = str.slice(1);
|
||||
if (hexBody.length === 6) {
|
||||
return { hex: Number.parseInt(hexBody, 16), alpha: 1 };
|
||||
}
|
||||
if (hexBody.length === 8) {
|
||||
const rgb = Number.parseInt(hexBody.slice(0, 6), 16);
|
||||
const aByte = Number.parseInt(hexBody.slice(6, 8), 16);
|
||||
return { hex: rgb, alpha: aByte / 255 };
|
||||
}
|
||||
}
|
||||
// 0xRRGGBB
|
||||
if (str.startsWith("0x")) {
|
||||
return { hex: Number.parseInt(str, 16), alpha: 1 };
|
||||
}
|
||||
return { hex: 0xffffff, alpha: 1 };
|
||||
};
|
||||
|
||||
const drawDocuments = useCallback(
|
||||
(g: PixiGraphics) => {
|
||||
g.clear();
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.type !== "document") return;
|
||||
|
||||
// World-space coordinates – container transform handles pan/zoom
|
||||
const screenX = node.x;
|
||||
const screenY = node.y;
|
||||
const nodeSize = node.size;
|
||||
|
||||
const docWidth = nodeSize * 1.4;
|
||||
const docHeight = nodeSize * 0.9;
|
||||
|
||||
// Choose colors similar to canvas version
|
||||
const fill = node.isDragging
|
||||
? colors.document.accent
|
||||
: node.isHovered
|
||||
? colors.document.secondary
|
||||
: colors.document.primary;
|
||||
|
||||
const strokeCol = node.isDragging
|
||||
? colors.document.glow
|
||||
: node.isHovered
|
||||
? colors.document.accent
|
||||
: colors.document.border;
|
||||
|
||||
const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fill);
|
||||
const { hex: strokeHex, alpha: strokeAlpha } = toHexAlpha(strokeCol);
|
||||
|
||||
// Stroke first then fill for proper shape borders
|
||||
const docStrokeWidth =
|
||||
(node.isDragging ? 3 : node.isHovered ? 2 : 1) / zoom;
|
||||
g.lineStyle(docStrokeWidth, strokeHex, strokeAlpha);
|
||||
g.beginFill(fillHex, fillAlpha);
|
||||
|
||||
const radius = zoom < 0.3 ? 6 : 12;
|
||||
g.drawRoundedRect(
|
||||
screenX - docWidth / 2,
|
||||
screenY - docHeight / 2,
|
||||
docWidth,
|
||||
docHeight,
|
||||
radius,
|
||||
);
|
||||
g.endFill();
|
||||
|
||||
// Inner highlight for glass effect (match GraphCanvas)
|
||||
if (zoom >= 0.3 && (node.isHovered || node.isDragging)) {
|
||||
const { hex: hlHex } = toHexAlpha("#ffffff");
|
||||
// Inner highlight stroke width constant
|
||||
const innerStroke = 1 / zoom;
|
||||
g.lineStyle(innerStroke, hlHex, 0.1);
|
||||
g.drawRoundedRect(
|
||||
screenX - docWidth / 2 + 1,
|
||||
screenY - docHeight / 2 + 1,
|
||||
docWidth - 2,
|
||||
docHeight - 2,
|
||||
radius - 1,
|
||||
);
|
||||
g.stroke();
|
||||
}
|
||||
});
|
||||
},
|
||||
[nodes, zoom],
|
||||
);
|
||||
|
||||
/* ---------- Memories layer ---------- */
|
||||
const drawMemories = useCallback(
|
||||
(g: PixiGraphics) => {
|
||||
g.clear();
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.type !== "memory") return;
|
||||
|
||||
const mem = node.data as MemoryEntry;
|
||||
const screenX = node.x;
|
||||
const screenY = node.y;
|
||||
const nodeSize = node.size;
|
||||
|
||||
const radius = nodeSize / 2;
|
||||
|
||||
// status checks
|
||||
const isForgotten =
|
||||
mem?.isForgotten ||
|
||||
(mem?.forgetAfter &&
|
||||
new Date(mem.forgetAfter).getTime() < Date.now());
|
||||
const isLatest = mem?.isLatest;
|
||||
const expiringSoon =
|
||||
mem?.forgetAfter &&
|
||||
!isForgotten &&
|
||||
new Date(mem.forgetAfter).getTime() - Date.now() <
|
||||
1000 * 60 * 60 * 24 * 7;
|
||||
const isNew =
|
||||
!isForgotten &&
|
||||
new Date(mem?.createdAt).getTime() >
|
||||
Date.now() - 1000 * 60 * 60 * 24;
|
||||
|
||||
// colours
|
||||
let fillColor = colors.memory.primary;
|
||||
let borderColor = colors.memory.border;
|
||||
let glowColor = colors.memory.glow;
|
||||
|
||||
if (isForgotten) {
|
||||
fillColor = colors.status.forgotten;
|
||||
borderColor = "rgba(220,38,38,0.3)";
|
||||
glowColor = "rgba(220,38,38,0.2)";
|
||||
} else if (expiringSoon) {
|
||||
borderColor = colors.status.expiring;
|
||||
glowColor = colors.accent.amber;
|
||||
} else if (isNew) {
|
||||
borderColor = colors.status.new;
|
||||
glowColor = colors.accent.emerald;
|
||||
}
|
||||
|
||||
if (node.isDragging) {
|
||||
fillColor = colors.memory.accent;
|
||||
borderColor = glowColor;
|
||||
} else if (node.isHovered) {
|
||||
fillColor = colors.memory.secondary;
|
||||
}
|
||||
|
||||
const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fillColor);
|
||||
const { hex: borderHex, alpha: borderAlpha } =
|
||||
toHexAlpha(borderColor);
|
||||
|
||||
// Match canvas behavior: multiply by isLatest global alpha
|
||||
const globalAlpha = isLatest ? 1 : 0.4;
|
||||
const finalFillAlpha = globalAlpha * fillAlpha;
|
||||
const finalStrokeAlpha = globalAlpha * borderAlpha;
|
||||
// Stroke first then fill for visible border
|
||||
const memStrokeW =
|
||||
(node.isDragging ? 3 : node.isHovered ? 2 : 1.5) / zoom;
|
||||
g.lineStyle(memStrokeW, borderHex, finalStrokeAlpha);
|
||||
g.beginFill(fillHex, finalFillAlpha);
|
||||
|
||||
if (zoom < 0.3) {
|
||||
// simplified circle when zoomed out
|
||||
g.drawCircle(screenX, screenY, radius);
|
||||
} else {
|
||||
// hexagon
|
||||
const sides = 6;
|
||||
const points: number[] = [];
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
|
||||
points.push(screenX + radius * Math.cos(angle));
|
||||
points.push(screenY + radius * Math.sin(angle));
|
||||
}
|
||||
g.drawPolygon(points);
|
||||
}
|
||||
|
||||
g.endFill();
|
||||
|
||||
// Status overlays (forgotten / new) – match GraphCanvas visuals
|
||||
if (isForgotten) {
|
||||
const { hex: crossHex, alpha: crossAlpha } = toHexAlpha(
|
||||
"rgba(220,38,38,0.4)",
|
||||
);
|
||||
// Cross/ dot overlay stroke widths constant
|
||||
const overlayStroke = 2 / zoom;
|
||||
g.lineStyle(overlayStroke, crossHex, globalAlpha * crossAlpha);
|
||||
const rCross = nodeSize * 0.25;
|
||||
g.moveTo(screenX - rCross, screenY - rCross);
|
||||
g.lineTo(screenX + rCross, screenY + rCross);
|
||||
g.moveTo(screenX + rCross, screenY - rCross);
|
||||
g.lineTo(screenX - rCross, screenY + rCross);
|
||||
g.stroke();
|
||||
} else if (isNew) {
|
||||
const { hex: dotHex, alpha: dotAlpha } = toHexAlpha(
|
||||
colors.status.new,
|
||||
);
|
||||
// Dot scales with node (GraphCanvas behaviour)
|
||||
const dotRadius = Math.max(2, nodeSize * 0.15);
|
||||
g.beginFill(dotHex, globalAlpha * dotAlpha);
|
||||
g.drawCircle(
|
||||
screenX + nodeSize * 0.25,
|
||||
screenY - nodeSize * 0.25,
|
||||
dotRadius,
|
||||
);
|
||||
g.endFill();
|
||||
}
|
||||
});
|
||||
},
|
||||
[nodes, zoom],
|
||||
);
|
||||
|
||||
/* ---------- Edges layer ---------- */
|
||||
// Helper: draw dashed quadratic curve to approximate canvas setLineDash
|
||||
const drawDashedQuadratic = useCallback(
|
||||
(
|
||||
g: PixiGraphics,
|
||||
sx: number,
|
||||
sy: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
dash = 10,
|
||||
gap = 5,
|
||||
) => {
|
||||
// Sample the curve and accumulate lines per dash to avoid overdraw
|
||||
const curveLength = Math.sqrt((sx - tx) ** 2 + (sy - ty) ** 2);
|
||||
const totalSamples = Math.max(
|
||||
20,
|
||||
Math.min(120, Math.floor(curveLength / 10)),
|
||||
);
|
||||
let prevX = sx;
|
||||
let prevY = sy;
|
||||
let distanceSinceToggle = 0;
|
||||
let drawSegment = true;
|
||||
let hasActiveDash = false;
|
||||
let dashStartX = sx;
|
||||
let dashStartY = sy;
|
||||
|
||||
for (let i = 1; i <= totalSamples; i++) {
|
||||
const t = i / totalSamples;
|
||||
const mt = 1 - t;
|
||||
const x = mt * mt * sx + 2 * mt * t * cx + t * t * tx;
|
||||
const y = mt * mt * sy + 2 * mt * t * cy + t * t * ty;
|
||||
|
||||
const dx = x - prevX;
|
||||
const dy = y - prevY;
|
||||
const segLen = Math.sqrt(dx * dx + dy * dy);
|
||||
distanceSinceToggle += segLen;
|
||||
|
||||
if (drawSegment) {
|
||||
if (!hasActiveDash) {
|
||||
dashStartX = prevX;
|
||||
dashStartY = prevY;
|
||||
hasActiveDash = true;
|
||||
}
|
||||
}
|
||||
|
||||
const threshold = drawSegment ? dash : gap;
|
||||
if (distanceSinceToggle >= threshold) {
|
||||
// end of current phase
|
||||
if (drawSegment && hasActiveDash) {
|
||||
g.moveTo(dashStartX, dashStartY);
|
||||
g.lineTo(prevX, prevY);
|
||||
g.stroke();
|
||||
hasActiveDash = false;
|
||||
}
|
||||
distanceSinceToggle = 0;
|
||||
drawSegment = !drawSegment;
|
||||
// If we transition into draw mode, start a new dash at current segment start
|
||||
if (drawSegment) {
|
||||
dashStartX = prevX;
|
||||
dashStartY = prevY;
|
||||
hasActiveDash = true;
|
||||
}
|
||||
}
|
||||
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
// Flush any active dash at the end
|
||||
if (drawSegment && hasActiveDash) {
|
||||
g.moveTo(dashStartX, dashStartY);
|
||||
g.lineTo(prevX, prevY);
|
||||
g.stroke();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const drawEdges = useCallback(
|
||||
(g: PixiGraphics) => {
|
||||
g.clear();
|
||||
|
||||
// Match GraphCanvas LOD behaviour
|
||||
const useSimplified = zoom < 0.3;
|
||||
|
||||
// quick node lookup
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
edges.forEach((edge) => {
|
||||
// Skip very weak doc-memory edges when zoomed out – behaviour copied from GraphCanvas
|
||||
if (
|
||||
useSimplified &&
|
||||
edge.edgeType === "doc-memory" &&
|
||||
(edge.visualProps?.opacity ?? 1) < 0.3
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const source = nodeMap.get(edge.source);
|
||||
const target = nodeMap.get(edge.target);
|
||||
if (!source || !target) return;
|
||||
|
||||
const sx = source.x;
|
||||
const sy = source.y;
|
||||
const tx = target.x;
|
||||
const ty = target.y;
|
||||
|
||||
// No viewport culling here because container transform handles visibility
|
||||
|
||||
let lineWidth = Math.max(1, edge.visualProps?.thickness ?? 1);
|
||||
// Use opacity exactly as provided to match GraphCanvas behaviour
|
||||
let opacity = edge.visualProps.opacity;
|
||||
let col = edge.color || colors.connection.weak;
|
||||
|
||||
if (edge.edgeType === "doc-memory") {
|
||||
lineWidth = 1;
|
||||
opacity = 0.9;
|
||||
col = colors.connection.memory;
|
||||
|
||||
if (useSimplified && opacity < 0.3) return;
|
||||
} else if (edge.edgeType === "doc-doc") {
|
||||
opacity = Math.max(0, edge.similarity * 0.5);
|
||||
lineWidth = Math.max(1, edge.similarity * 2);
|
||||
col = colors.connection.medium;
|
||||
if (edge.similarity > 0.85) col = colors.connection.strong;
|
||||
} else if (edge.edgeType === "version") {
|
||||
col = edge.color || colors.relations.updates;
|
||||
opacity = 0.8;
|
||||
lineWidth = 2;
|
||||
}
|
||||
|
||||
const { hex: strokeHex, alpha: colorAlpha } = toHexAlpha(col);
|
||||
const finalEdgeAlpha = Math.max(0, Math.min(1, opacity * colorAlpha));
|
||||
|
||||
// Always use round line caps (same as Canvas 2D)
|
||||
const screenLineWidth = lineWidth / zoom;
|
||||
g.lineStyle(screenLineWidth, strokeHex, finalEdgeAlpha);
|
||||
|
||||
if (edge.edgeType === "version") {
|
||||
// double line effect to match canvas (outer thicker, faint + inner thin)
|
||||
g.lineStyle(3 / zoom, strokeHex, finalEdgeAlpha * 0.3);
|
||||
g.moveTo(sx, sy);
|
||||
g.lineTo(tx, ty);
|
||||
g.stroke();
|
||||
|
||||
g.lineStyle(1 / zoom, strokeHex, finalEdgeAlpha);
|
||||
g.moveTo(sx, sy);
|
||||
g.lineTo(tx, ty);
|
||||
g.stroke();
|
||||
|
||||
// arrow head
|
||||
const angle = Math.atan2(ty - sy, tx - sx);
|
||||
const arrowLen = Math.max(6 / zoom, 8);
|
||||
const nodeRadius = target.size / 2;
|
||||
const ax = tx - Math.cos(angle) * (nodeRadius + 2);
|
||||
const ay = ty - Math.sin(angle) * (nodeRadius + 2);
|
||||
|
||||
g.moveTo(ax, ay);
|
||||
g.lineTo(
|
||||
ax - arrowLen * Math.cos(angle - Math.PI / 6),
|
||||
ay - arrowLen * Math.sin(angle - Math.PI / 6),
|
||||
);
|
||||
g.moveTo(ax, ay);
|
||||
g.lineTo(
|
||||
ax - arrowLen * Math.cos(angle + Math.PI / 6),
|
||||
ay - arrowLen * Math.sin(angle + Math.PI / 6),
|
||||
);
|
||||
g.stroke();
|
||||
} else {
|
||||
// straight line when zoomed out; dashed curved when zoomed in for doc-doc
|
||||
if (useSimplified) {
|
||||
g.moveTo(sx, sy);
|
||||
g.lineTo(tx, ty);
|
||||
g.stroke();
|
||||
} else {
|
||||
const midX = (sx + tx) / 2;
|
||||
const midY = (sy + ty) / 2;
|
||||
const dx = tx - sx;
|
||||
const dy = ty - sy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ctrlOffset =
|
||||
edge.edgeType === "doc-memory" ? 15 : Math.min(30, dist * 0.2);
|
||||
|
||||
const cx = midX + ctrlOffset * (dy / dist);
|
||||
const cy = midY - ctrlOffset * (dx / dist);
|
||||
|
||||
if (edge.edgeType === "doc-doc") {
|
||||
if (useSimplified) {
|
||||
// Straight line when zoomed out (no dash)
|
||||
g.moveTo(sx, sy);
|
||||
g.quadraticCurveTo(cx, cy, tx, ty);
|
||||
g.stroke();
|
||||
} else {
|
||||
// Dash lengths scale with zoom to keep screen size constant
|
||||
const dash = 10 / zoom;
|
||||
const gap = 5 / zoom;
|
||||
drawDashedQuadratic(g, sx, sy, cx, cy, tx, ty, dash, gap);
|
||||
}
|
||||
} else {
|
||||
g.moveTo(sx, sy);
|
||||
g.quadraticCurveTo(cx, cy, tx, ty);
|
||||
g.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[edges, nodes, zoom, width, drawDashedQuadratic],
|
||||
);
|
||||
|
||||
/* ---------- pointer handlers (unchanged) ---------- */
|
||||
// Pointer move (pan or drag)
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const mouseEvent = {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent;
|
||||
|
||||
if (draggingNodeId) {
|
||||
// Node dragging handled elsewhere (future steps)
|
||||
onNodeDragMove(mouseEvent);
|
||||
} else if (isPanningRef.current) {
|
||||
onPanMove(mouseEvent);
|
||||
}
|
||||
|
||||
// Track movement for distinguishing click vs drag/pan
|
||||
if (pointerDownPosRef.current) {
|
||||
const dx = e.clientX - pointerDownPosRef.current.x;
|
||||
const dy = e.clientY - pointerDownPosRef.current.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > 3) pointerMovedRef.current = true;
|
||||
}
|
||||
|
||||
// Hover detection
|
||||
const nodeId = getNodeAtPosition(e.clientX, e.clientY);
|
||||
if (nodeId !== currentHoveredRef.current) {
|
||||
currentHoveredRef.current = nodeId;
|
||||
onNodeHover(nodeId);
|
||||
}
|
||||
},
|
||||
[
|
||||
draggingNodeId,
|
||||
onNodeDragMove,
|
||||
onPanMove,
|
||||
onNodeHover,
|
||||
getNodeAtPosition,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const mouseEvent = {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent;
|
||||
|
||||
const nodeId = getNodeAtPosition(e.clientX, e.clientY);
|
||||
if (nodeId) {
|
||||
onNodeDragStart(nodeId, mouseEvent);
|
||||
// drag handled externally
|
||||
} else {
|
||||
onPanStart(mouseEvent);
|
||||
isPanningRef.current = true;
|
||||
}
|
||||
pointerDownPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
pointerMovedRef.current = false;
|
||||
},
|
||||
[onPanStart, onNodeDragStart, getNodeAtPosition],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const wasPanning = isPanningRef.current;
|
||||
if (draggingNodeId) onNodeDragEnd();
|
||||
else if (wasPanning) onPanEnd();
|
||||
|
||||
// Consider it a click if not panning and movement was minimal
|
||||
if (!wasPanning && !pointerMovedRef.current) {
|
||||
const nodeId = getNodeAtPosition(e.clientX, e.clientY);
|
||||
if (nodeId) onNodeClick(nodeId);
|
||||
}
|
||||
|
||||
isPanningRef.current = false;
|
||||
pointerDownPosRef.current = null;
|
||||
pointerMovedRef.current = false;
|
||||
},
|
||||
[draggingNodeId, onNodeDragEnd, onPanEnd, getNodeAtPosition, onNodeClick],
|
||||
);
|
||||
|
||||
// Click handler – opens detail panel
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isPanningRef.current) return;
|
||||
const nodeId = getNodeAtPosition(e.clientX, e.clientY);
|
||||
if (nodeId) onNodeClick(nodeId);
|
||||
},
|
||||
[getNodeAtPosition, onNodeClick],
|
||||
);
|
||||
|
||||
// Click handled in pointer up to avoid duplicate events
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Accumulate deltas
|
||||
pendingWheelDeltaRef.current.dx += e.deltaX;
|
||||
pendingWheelDeltaRef.current.dy += e.deltaY;
|
||||
|
||||
// Schedule a single update per frame
|
||||
if (wheelRafRef.current === null) {
|
||||
wheelRafRef.current = requestAnimationFrame(() => {
|
||||
const { dx, dy } = pendingWheelDeltaRef.current;
|
||||
pendingWheelDeltaRef.current = { dx: 0, dy: 0 };
|
||||
|
||||
// @ts-expect-error
|
||||
onWheel({
|
||||
deltaY: dy,
|
||||
deltaX: dx,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
currentTarget: containerRef.current,
|
||||
nativeEvent: e.nativeEvent,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.WheelEvent);
|
||||
|
||||
wheelRafRef.current = null;
|
||||
|
||||
// nothing else – caching removed
|
||||
});
|
||||
}
|
||||
},
|
||||
[onWheel],
|
||||
);
|
||||
|
||||
// Cleanup any pending RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wheelRafRef.current !== null) {
|
||||
cancelAnimationFrame(wheelRafRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
onDoubleClick={(ev) =>
|
||||
onDoubleClick?.(ev as unknown as React.MouseEvent)
|
||||
}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter")
|
||||
handleClick(ev as unknown as React.MouseEvent<HTMLDivElement>);
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerLeave={() => {
|
||||
if (draggingNodeId) onNodeDragEnd();
|
||||
if (isPanningRef.current) onPanEnd();
|
||||
isPanningRef.current = false;
|
||||
pointerDownPosRef.current = null;
|
||||
pointerMovedRef.current = false;
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onWheel={handleWheel}
|
||||
ref={containerRef}
|
||||
role="application"
|
||||
style={{
|
||||
cursor: draggingNodeId ? "grabbing" : "move",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
}}
|
||||
>
|
||||
<Application
|
||||
preference="webgl"
|
||||
antialias
|
||||
autoDensity
|
||||
backgroundColor={0x0f1419}
|
||||
height={height}
|
||||
resolution={
|
||||
typeof window !== "undefined" ? window.devicePixelRatio : 1
|
||||
}
|
||||
width={width}
|
||||
>
|
||||
{/* Grid background (not affected by world transform) */}
|
||||
<pixiGraphics ref={gridG} draw={() => {}} />
|
||||
|
||||
{/* World container that pans/zooms as a single transform */}
|
||||
<pixiContainer ref={worldContainerRef}>
|
||||
{/* Edges */}
|
||||
<pixiGraphics ref={edgesG} draw={() => {}} />
|
||||
|
||||
{/* Documents */}
|
||||
<pixiGraphics ref={docsG} draw={() => {}} />
|
||||
|
||||
{/* Memories */}
|
||||
<pixiGraphics ref={memsG} draw={() => {}} />
|
||||
</pixiContainer>
|
||||
</Application>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
GraphWebGLCanvas.displayName = "GraphWebGLCanvas";
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
calculateSemanticSimilarity,
|
||||
getConnectionVisualProps,
|
||||
getMagicalConnectionColor,
|
||||
} from "@lib/similarity";
|
||||
import { useMemo } from "react";
|
||||
import { colors, LAYOUT_CONSTANTS } from "../constants";
|
||||
import type {
|
||||
DocumentsResponse,
|
||||
DocumentWithMemories,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
MemoryEntry,
|
||||
MemoryRelation,
|
||||
} from "../types";
|
||||
|
||||
export function useGraphData(
|
||||
data: DocumentsResponse | null,
|
||||
selectedSpace: string,
|
||||
nodePositions: Map<string, { x: number; y: number }>,
|
||||
draggingNodeId: string | null,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!data?.documents) return { nodes: [], edges: [] };
|
||||
|
||||
const allNodes: GraphNode[] = [];
|
||||
const allEdges: GraphEdge[] = [];
|
||||
|
||||
// Filter documents that have memories in selected space
|
||||
const filteredDocuments = data.documents.map((doc) => ({
|
||||
...doc,
|
||||
memoryEntries:
|
||||
selectedSpace === "all"
|
||||
? doc.memoryEntries
|
||||
: doc.memoryEntries.filter(
|
||||
(memory) =>
|
||||
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
|
||||
selectedSpace,
|
||||
),
|
||||
}));
|
||||
|
||||
// Group documents by space for better clustering
|
||||
const documentsBySpace = new Map<string, typeof filteredDocuments>();
|
||||
filteredDocuments.forEach((doc) => {
|
||||
const docSpace =
|
||||
doc.memoryEntries[0]?.spaceContainerTag ??
|
||||
doc.memoryEntries[0]?.spaceId ??
|
||||
"default";
|
||||
if (!documentsBySpace.has(docSpace)) {
|
||||
documentsBySpace.set(docSpace, []);
|
||||
}
|
||||
const spaceDocsArr = documentsBySpace.get(docSpace);
|
||||
if (spaceDocsArr) {
|
||||
spaceDocsArr.push(doc);
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced Layout with Space Separation
|
||||
const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
|
||||
LAYOUT_CONSTANTS;
|
||||
|
||||
/* 1. Build DOCUMENT nodes with space-aware clustering */
|
||||
const documentNodes: GraphNode[] = [];
|
||||
let spaceIndex = 0;
|
||||
|
||||
documentsBySpace.forEach((spaceDocs) => {
|
||||
const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2;
|
||||
const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing;
|
||||
const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing;
|
||||
const spaceCenterX = centerX + spaceOffsetX;
|
||||
const spaceCenterY = centerY + spaceOffsetY;
|
||||
|
||||
spaceDocs.forEach((doc, docIndex) => {
|
||||
// Create proper circular layout with concentric rings
|
||||
const docsPerRing = 6; // Start with 6 docs in inner ring
|
||||
let currentRing = 0;
|
||||
let docsInCurrentRing = docsPerRing;
|
||||
let totalDocsInPreviousRings = 0;
|
||||
|
||||
// Find which ring this document belongs to
|
||||
while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) {
|
||||
totalDocsInPreviousRings += docsInCurrentRing;
|
||||
currentRing++;
|
||||
docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs
|
||||
}
|
||||
|
||||
// Position within the ring
|
||||
const positionInRing = docIndex - totalDocsInPreviousRings;
|
||||
const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2;
|
||||
|
||||
// Radius increases significantly with each ring
|
||||
const baseRadius = documentSpacing * 0.8;
|
||||
const radius =
|
||||
currentRing === 0
|
||||
? baseRadius
|
||||
: baseRadius + currentRing * documentSpacing * 1.2;
|
||||
|
||||
const defaultX = spaceCenterX + Math.cos(angleInRing) * radius;
|
||||
const defaultY = spaceCenterY + Math.sin(angleInRing) * radius;
|
||||
|
||||
const customPos = nodePositions.get(doc.id);
|
||||
|
||||
documentNodes.push({
|
||||
id: doc.id,
|
||||
type: "document",
|
||||
x: customPos?.x ?? defaultX,
|
||||
y: customPos?.y ?? defaultY,
|
||||
data: doc,
|
||||
size: 58,
|
||||
color: colors.document.primary,
|
||||
isHovered: false,
|
||||
isDragging: draggingNodeId === doc.id,
|
||||
} satisfies GraphNode);
|
||||
});
|
||||
|
||||
spaceIndex++;
|
||||
});
|
||||
|
||||
/* 2. Gentle document collision avoidance with dampening */
|
||||
const minDocDist = LAYOUT_CONSTANTS.minDocDist;
|
||||
|
||||
// Reduced iterations and gentler repulsion for smoother movement
|
||||
for (let iter = 0; iter < 2; iter++) {
|
||||
documentNodes.forEach((nodeA) => {
|
||||
documentNodes.forEach((nodeB) => {
|
||||
if (nodeA.id >= nodeB.id) return;
|
||||
|
||||
// Only repel documents in the same space
|
||||
const spaceA =
|
||||
(nodeA.data as DocumentWithMemories).memoryEntries[0]
|
||||
?.spaceContainerTag ??
|
||||
(nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
|
||||
"default";
|
||||
const spaceB =
|
||||
(nodeB.data as DocumentWithMemories).memoryEntries[0]
|
||||
?.spaceContainerTag ??
|
||||
(nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
|
||||
"default";
|
||||
|
||||
if (spaceA !== spaceB) return;
|
||||
|
||||
const dx = nodeB.x - nodeA.x;
|
||||
const dy = nodeB.y - nodeA.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
|
||||
if (dist < minDocDist) {
|
||||
// Much gentler push with dampening
|
||||
const push = (minDocDist - dist) / 8;
|
||||
const dampening = Math.max(0.1, Math.min(1, dist / minDocDist));
|
||||
const smoothPush = push * dampening * 0.5;
|
||||
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
nodeA.x -= nx * smoothPush;
|
||||
nodeA.y -= ny * smoothPush;
|
||||
nodeB.x += nx * smoothPush;
|
||||
nodeB.y += ny * smoothPush;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
allNodes.push(...documentNodes);
|
||||
|
||||
/* 3. Add memories around documents WITH doc-memory connections */
|
||||
documentNodes.forEach((docNode) => {
|
||||
const memoryNodeMap = new Map<string, GraphNode>();
|
||||
const doc = docNode.data as DocumentWithMemories;
|
||||
|
||||
doc.memoryEntries.forEach((memory, memIndex) => {
|
||||
const memoryId = `${memory.id}`;
|
||||
const customMemPos = nodePositions.get(memoryId);
|
||||
|
||||
const clusterAngle =
|
||||
(memIndex / doc.memoryEntries.length) * Math.PI * 2;
|
||||
const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7;
|
||||
const distance = clusterRadius * variation;
|
||||
|
||||
const seed =
|
||||
memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36);
|
||||
const offsetX = Math.sin(seed) * 0.5 * 40;
|
||||
const offsetY = Math.cos(seed) * 0.5 * 40;
|
||||
|
||||
const defaultMemX =
|
||||
docNode.x + Math.cos(clusterAngle) * distance + offsetX;
|
||||
const defaultMemY =
|
||||
docNode.y + Math.sin(clusterAngle) * distance + offsetY;
|
||||
|
||||
if (!memoryNodeMap.has(memoryId)) {
|
||||
const memoryNode: GraphNode = {
|
||||
id: memoryId,
|
||||
type: "memory",
|
||||
x: customMemPos?.x ?? defaultMemX,
|
||||
y: customMemPos?.y ?? defaultMemY,
|
||||
data: memory,
|
||||
size: Math.max(
|
||||
32,
|
||||
Math.min(48, (memory.memory?.length || 50) * 0.5),
|
||||
),
|
||||
color: colors.memory.primary,
|
||||
isHovered: false,
|
||||
isDragging: draggingNodeId === memoryId,
|
||||
};
|
||||
memoryNodeMap.set(memoryId, memoryNode);
|
||||
allNodes.push(memoryNode);
|
||||
}
|
||||
|
||||
// Create doc-memory edge with similarity
|
||||
allEdges.push({
|
||||
id: `edge-${docNode.id}-${memory.id}`,
|
||||
source: docNode.id,
|
||||
target: memoryId,
|
||||
similarity: 1,
|
||||
visualProps: getConnectionVisualProps(1),
|
||||
color: colors.connection.memory,
|
||||
edgeType: "doc-memory",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Build mapping of memoryId -> nodeId for version chains
|
||||
const memNodeIdMap = new Map<string, string>();
|
||||
allNodes.forEach((n) => {
|
||||
if (n.type === "memory") {
|
||||
memNodeIdMap.set((n.data as MemoryEntry).id, n.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Add version-chain edges (old -> new)
|
||||
data.documents.forEach((doc) => {
|
||||
doc.memoryEntries.forEach((mem: MemoryEntry) => {
|
||||
// Support both new object structure and legacy array/single parent fields
|
||||
let parentRelations: Record<string, MemoryRelation> = {};
|
||||
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
parentRelations = mem.memoryRelations;
|
||||
} else if (mem.parentMemoryId) {
|
||||
parentRelations = {
|
||||
[mem.parentMemoryId]: "updates" as MemoryRelation,
|
||||
};
|
||||
}
|
||||
Object.entries(parentRelations).forEach(([pid, relationType]) => {
|
||||
const fromId = memNodeIdMap.get(pid);
|
||||
const toId = memNodeIdMap.get(mem.id);
|
||||
if (fromId && toId) {
|
||||
allEdges.push({
|
||||
id: `version-${fromId}-${toId}`,
|
||||
source: fromId,
|
||||
target: toId,
|
||||
similarity: 1,
|
||||
visualProps: {
|
||||
opacity: 0.8,
|
||||
thickness: 1,
|
||||
glow: 0,
|
||||
pulseDuration: 3000,
|
||||
},
|
||||
// choose color based on relation type
|
||||
color: colors.relations[relationType] ?? colors.relations.updates,
|
||||
edgeType: "version",
|
||||
relationType: relationType as MemoryRelation,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Document-to-document similarity edges
|
||||
for (let i = 0; i < filteredDocuments.length; i++) {
|
||||
const docI = filteredDocuments[i];
|
||||
if (!docI) continue;
|
||||
|
||||
for (let j = i + 1; j < filteredDocuments.length; j++) {
|
||||
const docJ = filteredDocuments[j];
|
||||
if (!docJ) continue;
|
||||
|
||||
const sim = calculateSemanticSimilarity(
|
||||
docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
|
||||
docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
|
||||
);
|
||||
if (sim > 0.725) {
|
||||
allEdges.push({
|
||||
id: `doc-doc-${docI.id}-${docJ.id}`,
|
||||
source: docI.id,
|
||||
target: docJ.id,
|
||||
similarity: sim,
|
||||
visualProps: getConnectionVisualProps(sim),
|
||||
color: getMagicalConnectionColor(sim, 200),
|
||||
edgeType: "doc-doc",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: allNodes, edges: allEdges };
|
||||
}, [data, selectedSpace, nodePositions, draggingNodeId]);
|
||||
}
|
||||
|
|
@ -1,564 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { GRAPH_SETTINGS } from "../constants";
|
||||
import type { GraphNode } from "../types";
|
||||
|
||||
export function useGraphInteractions(
|
||||
variant: "console" | "consumer" = "console",
|
||||
) {
|
||||
const settings = GRAPH_SETTINGS[variant];
|
||||
|
||||
const [panX, setPanX] = useState(settings.initialPanX);
|
||||
const [panY, setPanY] = useState(settings.initialPanY);
|
||||
const [zoom, setZoom] = useState(settings.initialZoom);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
|
||||
const [dragStart, setDragStart] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
nodeX: 0,
|
||||
nodeY: 0,
|
||||
});
|
||||
const [nodePositions, setNodePositions] = useState<
|
||||
Map<string, { x: number; y: number }>
|
||||
>(new Map());
|
||||
|
||||
// Touch gesture state
|
||||
const [touchState, setTouchState] = useState<{
|
||||
touches: { id: number; x: number; y: number }[];
|
||||
lastDistance: number;
|
||||
lastCenter: { x: number; y: number };
|
||||
isGesturing: boolean;
|
||||
}>({
|
||||
touches: [],
|
||||
lastDistance: 0,
|
||||
lastCenter: { x: 0, y: 0 },
|
||||
isGesturing: false,
|
||||
});
|
||||
|
||||
// Animation state for smooth transitions
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Smooth animation helper
|
||||
const animateToViewState = useCallback(
|
||||
(
|
||||
targetPanX: number,
|
||||
targetPanY: number,
|
||||
targetZoom: number,
|
||||
duration = 300,
|
||||
) => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
|
||||
const startPanX = panX;
|
||||
const startPanY = panY;
|
||||
const startZoom = zoom;
|
||||
const startTime = Date.now();
|
||||
|
||||
setIsAnimating(true);
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease out cubic function for smooth transitions
|
||||
const easeOut = 1 - (1 - progress) ** 3;
|
||||
|
||||
const currentPanX = startPanX + (targetPanX - startPanX) * easeOut;
|
||||
const currentPanY = startPanY + (targetPanY - startPanY) * easeOut;
|
||||
const currentZoom = startZoom + (targetZoom - startZoom) * easeOut;
|
||||
|
||||
setPanX(currentPanX);
|
||||
setPanY(currentPanY);
|
||||
setZoom(currentZoom);
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
animationRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
},
|
||||
[panX, panY, zoom],
|
||||
);
|
||||
|
||||
// Node drag handlers
|
||||
const handleNodeDragStart = useCallback(
|
||||
(nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => {
|
||||
const node = nodes?.find((n) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
|
||||
setDraggingNodeId(nodeId);
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
nodeX: node.x,
|
||||
nodeY: node.y,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNodeDragMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!draggingNodeId) return;
|
||||
|
||||
const deltaX = (e.clientX - dragStart.x) / zoom;
|
||||
const deltaY = (e.clientY - dragStart.y) / zoom;
|
||||
|
||||
const newX = dragStart.nodeX + deltaX;
|
||||
const newY = dragStart.nodeY + deltaY;
|
||||
|
||||
setNodePositions((prev) =>
|
||||
new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
|
||||
);
|
||||
},
|
||||
[draggingNodeId, dragStart, zoom],
|
||||
);
|
||||
|
||||
const handleNodeDragEnd = useCallback(() => {
|
||||
setDraggingNodeId(null);
|
||||
}, []);
|
||||
|
||||
// Pan handlers
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX - panX, y: e.clientY - panY });
|
||||
},
|
||||
[panX, panY],
|
||||
);
|
||||
|
||||
const handlePanMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanning || draggingNodeId) return;
|
||||
|
||||
const newPanX = e.clientX - panStart.x;
|
||||
const newPanY = e.clientY - panStart.y;
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
},
|
||||
[isPanning, panStart, draggingNodeId],
|
||||
);
|
||||
|
||||
const handlePanEnd = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// Zoom handlers
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
// Always prevent default to stop browser navigation
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle horizontal scrolling (trackpad swipe) by converting to pan
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
// Horizontal scroll - pan the graph instead of zooming
|
||||
const panDelta = e.deltaX * 0.5;
|
||||
setPanX((prev) => prev - panDelta);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertical scroll - zoom behavior
|
||||
const delta = e.deltaY > 0 ? 0.97 : 1.03;
|
||||
const newZoom = Math.max(0.05, Math.min(3, zoom * delta));
|
||||
|
||||
// Get mouse position relative to the viewport
|
||||
let mouseX = e.clientX;
|
||||
let mouseY = e.clientY;
|
||||
|
||||
// Try to get the container bounds to make coordinates relative to the graph container
|
||||
const target = e.currentTarget;
|
||||
if (target && "getBoundingClientRect" in target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
// Calculate the world position of the mouse cursor
|
||||
const worldX = (mouseX - panX) / zoom;
|
||||
const worldY = (mouseY - panY) / zoom;
|
||||
|
||||
// Calculate new pan to keep the mouse position stationary
|
||||
const newPanX = mouseX - worldX * newZoom;
|
||||
const newPanY = mouseY - worldY * newZoom;
|
||||
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
);
|
||||
|
||||
const zoomIn = useCallback(
|
||||
(centerX?: number, centerY?: number, animate = true) => {
|
||||
const zoomFactor = 1.2;
|
||||
const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x
|
||||
|
||||
if (centerX !== undefined && centerY !== undefined) {
|
||||
// Mouse-centered zoom for programmatic zoom in
|
||||
const worldX = (centerX - panX) / zoom;
|
||||
const worldY = (centerY - panY) / zoom;
|
||||
const newPanX = centerX - worldX * newZoom;
|
||||
const newPanY = centerY - worldY * newZoom;
|
||||
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(newPanX, newPanY, newZoom, 200);
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
}
|
||||
} else {
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(panX, panY, newZoom, 200);
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
}
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY, isAnimating, animateToViewState],
|
||||
);
|
||||
|
||||
const zoomOut = useCallback(
|
||||
(centerX?: number, centerY?: number, animate = true) => {
|
||||
const zoomFactor = 0.8;
|
||||
const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x
|
||||
|
||||
if (centerX !== undefined && centerY !== undefined) {
|
||||
// Mouse-centered zoom for programmatic zoom out
|
||||
const worldX = (centerX - panX) / zoom;
|
||||
const worldY = (centerY - panY) / zoom;
|
||||
const newPanX = centerX - worldX * newZoom;
|
||||
const newPanY = centerY - worldY * newZoom;
|
||||
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(newPanX, newPanY, newZoom, 200);
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
}
|
||||
} else {
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(panX, panY, newZoom, 200);
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
}
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY, isAnimating, animateToViewState],
|
||||
);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setPanX(settings.initialPanX);
|
||||
setPanY(settings.initialPanY);
|
||||
setZoom(settings.initialZoom);
|
||||
setNodePositions(new Map());
|
||||
}, [settings]);
|
||||
|
||||
// Auto-fit graph to viewport
|
||||
const autoFitToViewport = useCallback(
|
||||
(
|
||||
nodes: GraphNode[],
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
options?: { occludedRightPx?: number; animate?: boolean },
|
||||
) => {
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
// Find the bounds of all nodes
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
minX = Math.min(minX, node.x - node.size / 2);
|
||||
maxX = Math.max(maxX, node.x + node.size / 2);
|
||||
minY = Math.min(minY, node.y - node.size / 2);
|
||||
maxY = Math.max(maxY, node.y + node.size / 2);
|
||||
});
|
||||
|
||||
// Calculate the center of the content
|
||||
const contentCenterX = (minX + maxX) / 2;
|
||||
const contentCenterY = (minY + maxY) / 2;
|
||||
|
||||
// Calculate the size of the content
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
|
||||
// Add padding (20% on each side)
|
||||
const paddingFactor = 1.4;
|
||||
const paddedWidth = contentWidth * paddingFactor;
|
||||
const paddedHeight = contentHeight * paddingFactor;
|
||||
|
||||
// Account for occluded area on the right (e.g., chat panel)
|
||||
const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0);
|
||||
const availableWidth = Math.max(1, viewportWidth - occludedRightPx);
|
||||
|
||||
// Calculate the zoom needed to fit the content within available width
|
||||
const zoomX = availableWidth / paddedWidth;
|
||||
const zoomY = viewportHeight / paddedHeight;
|
||||
const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3);
|
||||
|
||||
// Calculate pan to center the content within available area
|
||||
const availableCenterX = availableWidth / 2;
|
||||
const newPanX = availableCenterX - contentCenterX * newZoom;
|
||||
const newPanY = viewportHeight / 2 - contentCenterY * newZoom;
|
||||
|
||||
// Apply the new view (optional animation)
|
||||
if (options?.animate) {
|
||||
const steps = 8;
|
||||
const durationMs = 160; // snappy
|
||||
const intervalMs = Math.max(1, Math.floor(durationMs / steps));
|
||||
const startZoom = zoom;
|
||||
const startPanX = panX;
|
||||
const startPanY = panY;
|
||||
let i = 0;
|
||||
const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad
|
||||
const timer = setInterval(() => {
|
||||
i++;
|
||||
const t = ease(i / steps);
|
||||
setZoom(startZoom + (newZoom - startZoom) * t);
|
||||
setPanX(startPanX + (newPanX - startPanX) * t);
|
||||
setPanY(startPanY + (newPanY - startPanY) * t);
|
||||
if (i >= steps) clearInterval(timer);
|
||||
}, intervalMs);
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
);
|
||||
|
||||
// Touch gesture handlers for mobile pinch-to-zoom
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touches = Array.from(e.touches).map((touch) => ({
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
}));
|
||||
|
||||
if (touches.length >= 2) {
|
||||
// Start gesture with two or more fingers
|
||||
const touch1 = touches[0]!;
|
||||
const touch2 = touches[1]!;
|
||||
|
||||
const distance = Math.sqrt(
|
||||
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
|
||||
);
|
||||
|
||||
const center = {
|
||||
x: (touch1.x + touch2.x) / 2,
|
||||
y: (touch1.y + touch2.y) / 2,
|
||||
};
|
||||
|
||||
setTouchState({
|
||||
touches,
|
||||
lastDistance: distance,
|
||||
lastCenter: center,
|
||||
isGesturing: true,
|
||||
});
|
||||
} else {
|
||||
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const touches = Array.from(e.touches).map((touch) => ({
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
}));
|
||||
|
||||
if (touches.length >= 2 && touchState.isGesturing) {
|
||||
const touch1 = touches[0]!;
|
||||
const touch2 = touches[1]!;
|
||||
|
||||
const distance = Math.sqrt(
|
||||
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
|
||||
);
|
||||
|
||||
const center = {
|
||||
x: (touch1.x + touch2.x) / 2,
|
||||
y: (touch1.y + touch2.y) / 2,
|
||||
};
|
||||
|
||||
// Calculate zoom change based on pinch distance change
|
||||
const distanceChange = distance / touchState.lastDistance;
|
||||
const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange));
|
||||
|
||||
// Get canvas bounds for center calculation
|
||||
const canvas = e.currentTarget as HTMLElement;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const centerX = center.x - rect.left;
|
||||
const centerY = center.y - rect.top;
|
||||
|
||||
// Calculate the world position of the pinch center
|
||||
const worldX = (centerX - panX) / zoom;
|
||||
const worldY = (centerY - panY) / zoom;
|
||||
|
||||
// Calculate new pan to keep the pinch center stationary
|
||||
const newPanX = centerX - worldX * newZoom;
|
||||
const newPanY = centerY - worldY * newZoom;
|
||||
|
||||
// Calculate pan change based on center movement
|
||||
const centerDx = center.x - touchState.lastCenter.x;
|
||||
const centerDy = center.y - touchState.lastCenter.y;
|
||||
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX + centerDx);
|
||||
setPanY(newPanY + centerDy);
|
||||
|
||||
setTouchState({
|
||||
touches,
|
||||
lastDistance: distance,
|
||||
lastCenter: center,
|
||||
isGesturing: true,
|
||||
});
|
||||
} else if (touches.length === 1 && !touchState.isGesturing && isPanning) {
|
||||
// Single finger pan (only if not in gesture mode)
|
||||
const touch = touches[0]!;
|
||||
const newPanX = touch.x - panStart.x;
|
||||
const newPanY = touch.y - panStart.y;
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
}
|
||||
},
|
||||
[touchState, zoom, panX, panY, isPanning, panStart],
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
const touches = Array.from(e.touches).map((touch) => ({
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
}));
|
||||
|
||||
if (touches.length < 2) {
|
||||
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
|
||||
} else {
|
||||
setTouchState((prev) => ({ ...prev, touches }));
|
||||
}
|
||||
|
||||
if (touches.length === 0) {
|
||||
setIsPanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Center viewport on a specific world position (with animation)
|
||||
const centerViewportOn = useCallback(
|
||||
(
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
animate = true,
|
||||
) => {
|
||||
const newPanX = viewportWidth / 2 - worldX * zoom;
|
||||
const newPanY = viewportHeight / 2 - worldY * zoom;
|
||||
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(newPanX, newPanY, zoom, 400);
|
||||
} else {
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
}
|
||||
},
|
||||
[zoom, isAnimating, animateToViewState],
|
||||
);
|
||||
|
||||
// Node interaction handlers
|
||||
const handleNodeHover = useCallback((nodeId: string | null) => {
|
||||
setHoveredNode(nodeId);
|
||||
}, []);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
setSelectedNode(selectedNode === nodeId ? null : nodeId);
|
||||
},
|
||||
[selectedNode],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Calculate new zoom (zoom in by 1.5x)
|
||||
const zoomFactor = 1.5;
|
||||
const newZoom = Math.min(3, zoom * zoomFactor);
|
||||
|
||||
// Get mouse position relative to the container
|
||||
let mouseX = e.clientX;
|
||||
let mouseY = e.clientY;
|
||||
|
||||
// Try to get the container bounds to make coordinates relative to the graph container
|
||||
const target = e.currentTarget;
|
||||
if (target && "getBoundingClientRect" in target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
// Calculate the world position of the clicked point
|
||||
const worldX = (mouseX - panX) / zoom;
|
||||
const worldY = (mouseY - panY) / zoom;
|
||||
|
||||
// Calculate new pan to keep the clicked point in the same screen position
|
||||
const newPanX = mouseX - worldX * newZoom;
|
||||
const newPanY = mouseY - worldY * newZoom;
|
||||
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
hoveredNode,
|
||||
selectedNode,
|
||||
draggingNodeId,
|
||||
nodePositions,
|
||||
// Handlers
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
handleWheel,
|
||||
handleNodeHover,
|
||||
handleNodeClick,
|
||||
handleNodeDragStart,
|
||||
handleNodeDragMove,
|
||||
handleNodeDragEnd,
|
||||
handleDoubleClick,
|
||||
// Touch handlers
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
// Controls
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetView,
|
||||
autoFitToViewport,
|
||||
centerViewportOn,
|
||||
setSelectedNode,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
// Components
|
||||
|
||||
// Types and constants
|
||||
export {
|
||||
colors,
|
||||
GRAPH_SETTINGS,
|
||||
LAYOUT_CONSTANTS,
|
||||
POSITIONING,
|
||||
} from "./constants";
|
||||
export { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas";
|
||||
// Hooks
|
||||
export { useGraphData } from "./hooks/use-graph-data";
|
||||
export { useGraphInteractions } from "./hooks/use-graph-interactions";
|
||||
export { Legend } from "./legend";
|
||||
export { LoadingIndicator } from "./loading-indicator";
|
||||
export { MemoryGraph } from "./memory-graph";
|
||||
export { NodeDetailPanel } from "./node-detail-panel";
|
||||
export { SpacesDropdown } from "./spaces-dropdown";
|
||||
export * from "./types";
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useIsMobile } from "@hooks/use-mobile";
|
||||
import { cn } from "@lib/utils";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@ui/components/collapsible";
|
||||
import { GlassMenuEffect } from "@ui/other/glass-effect";
|
||||
import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { colors } from "./constants";
|
||||
import type { GraphEdge, GraphNode, LegendProps } from "./types";
|
||||
|
||||
// Cookie utility functions for legend state
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === "undefined") return;
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
||||
};
|
||||
|
||||
const getCookie = (name: string): string | null => {
|
||||
if (typeof document === "undefined") return null;
|
||||
const nameEQ = `${name}=`;
|
||||
const ca = document.cookie.split(";");
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
if (!c) continue;
|
||||
while (c.charAt(0) === " ") c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
interface ExtendedLegendProps extends LegendProps {
|
||||
id?: string;
|
||||
nodes?: GraphNode[];
|
||||
edges?: GraphEdge[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Legend = memo(function Legend({
|
||||
variant = "console",
|
||||
id,
|
||||
nodes = [],
|
||||
edges = [],
|
||||
isLoading = false,
|
||||
}: ExtendedLegendProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Load saved preference on client side
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
const savedState = getCookie("legendCollapsed");
|
||||
if (savedState === "true") {
|
||||
setIsExpanded(false);
|
||||
} else if (savedState === "false") {
|
||||
setIsExpanded(true);
|
||||
} else {
|
||||
// Default: collapsed on mobile, expanded on desktop
|
||||
setIsExpanded(!isMobile);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [isInitialized, isMobile]);
|
||||
|
||||
// Save to cookie when state changes
|
||||
const handleToggleExpanded = (expanded: boolean) => {
|
||||
setIsExpanded(expanded);
|
||||
setCookie("legendCollapsed", expanded ? "false" : "true");
|
||||
};
|
||||
|
||||
// Use explicit classes that Tailwind can detect
|
||||
const getPositioningClasses = () => {
|
||||
if (variant === "console") {
|
||||
// Both desktop and mobile use same positioning for console
|
||||
return "bottom-4 right-4";
|
||||
}
|
||||
if (variant === "consumer") {
|
||||
return isMobile ? "bottom-48 left-4" : "top-18 right-4";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getMobileSize = () => {
|
||||
if (!isMobile) return "";
|
||||
return isExpanded ? "max-w-xs" : "w-16 h-12";
|
||||
};
|
||||
|
||||
const hexagonClipPath =
|
||||
"polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
|
||||
|
||||
// Calculate stats
|
||||
const memoryCount = nodes.filter((n) => n.type === "memory").length;
|
||||
const documentCount = nodes.filter((n) => n.type === "document").length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 rounded-xl overflow-hidden w-fit h-fit",
|
||||
getPositioningClasses(),
|
||||
getMobileSize(),
|
||||
isMobile && "hidden md:block",
|
||||
)}
|
||||
id={id}
|
||||
>
|
||||
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Mobile and Desktop collapsed state */}
|
||||
{!isExpanded && (
|
||||
<CollapsibleTrigger className="w-full h-full p-2 flex items-center justify-center hover:bg-white/5 transition-colors">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="text-xs text-slate-300 font-medium">?</div>
|
||||
<ChevronUp className="w-3 h-3 text-slate-400" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
|
||||
{/* Expanded state */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-600/50">
|
||||
<div className="text-sm font-medium text-slate-100">Legend</div>
|
||||
<CollapsibleTrigger className="p-1 hover:bg-white/10 rounded">
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="text-xs text-slate-200 px-4 py-3 space-y-3">
|
||||
{/* Stats Section */}
|
||||
{!isLoading && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
||||
Statistics
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-xs">
|
||||
{memoryCount} memories
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-3 h-3 text-slate-300" />
|
||||
<span className="text-xs">
|
||||
{documentCount} documents
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-gradient-to-r from-slate-400 to-blue-400 rounded-full" />
|
||||
<span className="text-xs">
|
||||
{edges.length} connections
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Types */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
||||
Nodes
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-3 bg-white/8 border border-white/25 rounded-sm flex-shrink-0" />
|
||||
<span className="text-xs">Document</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 flex-shrink-0"
|
||||
style={{
|
||||
clipPath: hexagonClipPath,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">Memory (latest)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 opacity-40 flex-shrink-0"
|
||||
style={{
|
||||
clipPath: hexagonClipPath,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">Memory (older)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
||||
Status
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 bg-red-500/30 border border-red-500/80 relative flex-shrink-0"
|
||||
style={{
|
||||
clipPath: hexagonClipPath,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-red-400 text-xs leading-none">
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs">Forgotten</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 bg-blue-400/10 border-2 border-amber-500 flex-shrink-0"
|
||||
style={{
|
||||
clipPath: hexagonClipPath,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">Expiring soon</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 bg-blue-400/10 border-2 border-emerald-500 relative flex-shrink-0"
|
||||
style={{
|
||||
clipPath: hexagonClipPath,
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-emerald-500 rounded-full" />
|
||||
</div>
|
||||
<span className="text-xs">New memory</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Types */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
||||
Connections
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0 border-t border-slate-400 flex-shrink-0" />
|
||||
<span className="text-xs">Doc → Memory</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0 border-t-2 border-dashed border-slate-400 flex-shrink-0" />
|
||||
<span className="text-xs">Doc similarity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relation Types */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
||||
Relations
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
["updates", colors.relations.updates],
|
||||
["extends", colors.relations.extends],
|
||||
["derives", colors.relations.derives],
|
||||
].map(([label, color]) => (
|
||||
<div className="flex items-center gap-2" key={label}>
|
||||
<div
|
||||
className="w-4 h-0 border-t-2 flex-shrink-0"
|
||||
style={{ borderColor: color }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs capitalize"
|
||||
style={{ color: color }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Strength */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
||||
Similarity
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-slate-400/20 flex-shrink-0" />
|
||||
<span className="text-xs">Weak</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-slate-400/60 flex-shrink-0" />
|
||||
<span className="text-xs">Strong</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Legend.displayName = "Legend";
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@lib/utils";
|
||||
import { GlassMenuEffect } from "@ui/other/glass-effect";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import type { LoadingIndicatorProps } from "./types";
|
||||
|
||||
export const LoadingIndicator = memo<LoadingIndicatorProps>(
|
||||
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
|
||||
// Use explicit classes that Tailwind can detect
|
||||
const getPositioningClasses = () => {
|
||||
// Both variants use the same positioning for loadingIndicator
|
||||
return "top-20 left-4";
|
||||
};
|
||||
|
||||
if (!isLoading && !isLoadingMore) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 rounded-xl overflow-hidden",
|
||||
getPositioningClasses(),
|
||||
)}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<div className="relative z-10 text-slate-200 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
|
||||
<span className="text-sm">
|
||||
{isLoading
|
||||
? "Loading memory graph..."
|
||||
: `Loading more documents... (${totalLoaded})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LoadingIndicator.displayName = "LoadingIndicator";
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { colors } from "./constants";
|
||||
import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas";
|
||||
import { useGraphData } from "./hooks/use-graph-data";
|
||||
import { useGraphInteractions } from "./hooks/use-graph-interactions";
|
||||
import { Legend } from "./legend";
|
||||
import { LoadingIndicator } from "./loading-indicator";
|
||||
import { NavigationControls } from "./navigation-controls";
|
||||
import { NodeDetailPanel } from "./node-detail-panel";
|
||||
import { SpacesDropdown } from "./spaces-dropdown";
|
||||
|
||||
import type { MemoryGraphProps } from "./types";
|
||||
|
||||
export const MemoryGraph = ({
|
||||
children,
|
||||
documents,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
error,
|
||||
totalLoaded,
|
||||
hasMore,
|
||||
loadMoreDocuments,
|
||||
showSpacesSelector,
|
||||
variant = "console",
|
||||
legendId,
|
||||
highlightDocumentIds = [],
|
||||
highlightsVisible = true,
|
||||
occludedRightPx = 0,
|
||||
autoLoadOnViewport = true,
|
||||
}: MemoryGraphProps) => {
|
||||
// Derive showSpacesSelector from variant if not explicitly provided
|
||||
// console variant shows spaces selector, consumer variant hides it
|
||||
const finalShowSpacesSelector = showSpacesSelector ?? variant === "console";
|
||||
|
||||
const [selectedSpace, setSelectedSpace] = useState<string>("all");
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Create data object with dummy pagination to satisfy type requirements
|
||||
const data = useMemo(() => {
|
||||
return documents && documents.length > 0
|
||||
? {
|
||||
documents,
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
limit: documents.length,
|
||||
totalItems: documents.length,
|
||||
totalPages: 1,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}, [documents]);
|
||||
|
||||
// Graph interactions with variant-specific settings
|
||||
const {
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
/** hoveredNode currently unused within this component */
|
||||
hoveredNode: _hoveredNode,
|
||||
selectedNode,
|
||||
draggingNodeId,
|
||||
nodePositions,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
handleWheel,
|
||||
handleNodeHover,
|
||||
handleNodeClick,
|
||||
handleNodeDragStart,
|
||||
handleNodeDragMove,
|
||||
handleNodeDragEnd,
|
||||
handleDoubleClick,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
setSelectedNode,
|
||||
autoFitToViewport,
|
||||
centerViewportOn,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} = useGraphInteractions(variant);
|
||||
|
||||
// Graph data
|
||||
const { nodes, edges } = useGraphData(
|
||||
data,
|
||||
selectedSpace,
|
||||
nodePositions,
|
||||
draggingNodeId,
|
||||
);
|
||||
|
||||
// Auto-fit once per unique highlight set to show the full graph for context
|
||||
const lastFittedHighlightKeyRef = useRef<string>("");
|
||||
useEffect(() => {
|
||||
const highlightKey = highlightsVisible
|
||||
? highlightDocumentIds.join("|")
|
||||
: "";
|
||||
if (
|
||||
highlightKey &&
|
||||
highlightKey !== lastFittedHighlightKeyRef.current &&
|
||||
containerSize.width > 0 &&
|
||||
containerSize.height > 0 &&
|
||||
nodes.length > 0
|
||||
) {
|
||||
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
|
||||
occludedRightPx,
|
||||
animate: true,
|
||||
});
|
||||
lastFittedHighlightKeyRef.current = highlightKey;
|
||||
}
|
||||
}, [
|
||||
highlightsVisible,
|
||||
highlightDocumentIds,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
nodes.length,
|
||||
occludedRightPx,
|
||||
autoFitToViewport,
|
||||
]);
|
||||
|
||||
// Auto-fit graph when component mounts or nodes change significantly
|
||||
const hasAutoFittedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Only auto-fit once when we have nodes and container size
|
||||
if (
|
||||
!hasAutoFittedRef.current &&
|
||||
nodes.length > 0 &&
|
||||
containerSize.width > 0 &&
|
||||
containerSize.height > 0
|
||||
) {
|
||||
// Auto-fit to show all content for both variants
|
||||
// Add a small delay to ensure the canvas is fully initialized
|
||||
const timer = setTimeout(() => {
|
||||
autoFitToViewport(nodes, containerSize.width, containerSize.height);
|
||||
hasAutoFittedRef.current = true;
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [nodes, containerSize.width, containerSize.height, autoFitToViewport]);
|
||||
|
||||
// Reset auto-fit flag when nodes array becomes empty (switching views)
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) {
|
||||
hasAutoFittedRef.current = false;
|
||||
}
|
||||
}, [nodes.length]);
|
||||
|
||||
// Extract unique spaces from memories and calculate counts
|
||||
const { availableSpaces, spaceMemoryCounts } = useMemo(() => {
|
||||
if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} };
|
||||
|
||||
const spaceSet = new Set<string>();
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
data.documents.forEach((doc) => {
|
||||
doc.memoryEntries.forEach((memory) => {
|
||||
const spaceId = memory.spaceContainerTag || memory.spaceId || "default";
|
||||
spaceSet.add(spaceId);
|
||||
counts[spaceId] = (counts[spaceId] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
availableSpaces: Array.from(spaceSet).sort(),
|
||||
spaceMemoryCounts: counts,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// Handle container resize
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const newWidth = containerRef.current.clientWidth;
|
||||
const newHeight = containerRef.current.clientHeight;
|
||||
|
||||
// Only update if size actually changed and is valid
|
||||
setContainerSize((prev) => {
|
||||
if (prev.width !== newWidth || prev.height !== newHeight) {
|
||||
return { width: newWidth, height: newHeight };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Use a slight delay to ensure DOM is fully rendered
|
||||
const timer = setTimeout(updateSize, 0);
|
||||
updateSize(); // Also call immediately
|
||||
|
||||
window.addEventListener("resize", updateSize);
|
||||
|
||||
// Use ResizeObserver for more accurate container size detection
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", updateSize);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Enhanced node drag start that includes nodes data
|
||||
const handleNodeDragStartWithNodes = useCallback(
|
||||
(nodeId: string, e: React.MouseEvent) => {
|
||||
handleNodeDragStart(nodeId, e, nodes);
|
||||
},
|
||||
[handleNodeDragStart, nodes],
|
||||
);
|
||||
|
||||
// Navigation callbacks
|
||||
const handleCenter = useCallback(() => {
|
||||
if (nodes.length > 0) {
|
||||
// Calculate center of all nodes
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let count = 0;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
sumX += node.x;
|
||||
sumY += node.y;
|
||||
count++;
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
const centerX = sumX / count;
|
||||
const centerY = sumY / count;
|
||||
centerViewportOn(
|
||||
centerX,
|
||||
centerY,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [nodes, centerViewportOn, containerSize.width, containerSize.height]);
|
||||
|
||||
const handleAutoFit = useCallback(() => {
|
||||
if (
|
||||
nodes.length > 0 &&
|
||||
containerSize.width > 0 &&
|
||||
containerSize.height > 0
|
||||
) {
|
||||
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
|
||||
occludedRightPx,
|
||||
animate: true,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
occludedRightPx,
|
||||
autoFitToViewport,
|
||||
]);
|
||||
|
||||
// Get selected node data
|
||||
const selectedNodeData = useMemo(() => {
|
||||
if (!selectedNode) return null;
|
||||
return nodes.find((n) => n.id === selectedNode) || null;
|
||||
}, [selectedNode, nodes]);
|
||||
|
||||
// Viewport-based loading: load more when most documents are visible (optional)
|
||||
const checkAndLoadMore = useCallback(() => {
|
||||
if (
|
||||
isLoadingMore ||
|
||||
!hasMore ||
|
||||
!data?.documents ||
|
||||
data.documents.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
// Calculate viewport bounds
|
||||
const viewportBounds = {
|
||||
left: -panX / zoom - 200,
|
||||
right: (-panX + containerSize.width) / zoom + 200,
|
||||
top: -panY / zoom - 200,
|
||||
bottom: (-panY + containerSize.height) / zoom + 200,
|
||||
};
|
||||
|
||||
// Count visible documents
|
||||
const visibleDocuments = data.documents.filter((doc) => {
|
||||
const docNodes = nodes.filter(
|
||||
(node) => node.type === "document" && node.data.id === doc.id,
|
||||
);
|
||||
return docNodes.some(
|
||||
(node) =>
|
||||
node.x >= viewportBounds.left &&
|
||||
node.x <= viewportBounds.right &&
|
||||
node.y >= viewportBounds.top &&
|
||||
node.y <= viewportBounds.bottom,
|
||||
);
|
||||
});
|
||||
|
||||
// If 80% or more of documents are visible, load more
|
||||
const visibilityRatio = visibleDocuments.length / data.documents.length;
|
||||
if (visibilityRatio >= 0.8) {
|
||||
loadMoreDocuments();
|
||||
}
|
||||
}, [
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
data,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
nodes,
|
||||
loadMoreDocuments,
|
||||
]);
|
||||
|
||||
// Throttled version to avoid excessive checks
|
||||
const lastLoadCheckRef = useRef(0);
|
||||
const throttledCheckAndLoadMore = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastLoadCheckRef.current > 1000) {
|
||||
// Check at most once per second
|
||||
lastLoadCheckRef.current = now;
|
||||
checkAndLoadMore();
|
||||
}
|
||||
}, [checkAndLoadMore]);
|
||||
|
||||
// Monitor viewport changes to trigger loading
|
||||
useEffect(() => {
|
||||
if (!autoLoadOnViewport) return;
|
||||
throttledCheckAndLoadMore();
|
||||
}, [throttledCheckAndLoadMore, autoLoadOnViewport]);
|
||||
|
||||
// Initial load trigger when graph is first rendered
|
||||
useEffect(() => {
|
||||
if (!autoLoadOnViewport) return;
|
||||
if (data?.documents && data.documents.length > 0 && hasMore) {
|
||||
// Start loading more documents after initial render
|
||||
setTimeout(() => {
|
||||
throttledCheckAndLoadMore();
|
||||
}, 500); // Small delay to allow initial layout
|
||||
}
|
||||
}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center"
|
||||
style={{ backgroundColor: colors.background.primary }}
|
||||
>
|
||||
<div className="rounded-xl overflow-hidden">
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<div className="relative z-10 text-slate-200 px-6 py-4">
|
||||
Error loading documents: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full rounded-xl overflow-hidden"
|
||||
style={{ backgroundColor: colors.background.primary }}
|
||||
>
|
||||
{/* Spaces selector - only shown for console */}
|
||||
{finalShowSpacesSelector && availableSpaces.length > 0 && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<SpacesDropdown
|
||||
availableSpaces={availableSpaces}
|
||||
onSpaceChange={setSelectedSpace}
|
||||
selectedSpace={selectedSpace}
|
||||
spaceMemoryCounts={spaceMemoryCounts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
<LoadingIndicator
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
totalLoaded={totalLoaded}
|
||||
variant={variant}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<Legend
|
||||
edges={edges}
|
||||
id={legendId}
|
||||
isLoading={isLoading}
|
||||
nodes={nodes}
|
||||
variant={variant}
|
||||
/>
|
||||
|
||||
{/* Node detail panel */}
|
||||
<AnimatePresence>
|
||||
{selectedNodeData && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNodeData}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
variant={variant}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Show welcome screen when no memories exist */}
|
||||
{!isLoading &&
|
||||
(!data || !nodes.some((n) => n.type === "document")) &&
|
||||
children}
|
||||
|
||||
{/* Graph container */}
|
||||
<div
|
||||
className="w-full h-full relative overflow-hidden"
|
||||
ref={containerRef}
|
||||
style={{
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
}}
|
||||
>
|
||||
{containerSize.width > 0 && containerSize.height > 0 && (
|
||||
<GraphCanvas
|
||||
draggingNodeId={draggingNodeId}
|
||||
edges={edges}
|
||||
height={containerSize.height}
|
||||
nodes={nodes}
|
||||
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDragEnd={handleNodeDragEnd}
|
||||
onNodeDragMove={handleNodeDragMove}
|
||||
onNodeDragStart={handleNodeDragStartWithNodes}
|
||||
onNodeHover={handleNodeHover}
|
||||
onPanEnd={handlePanEnd}
|
||||
onPanMove={handlePanMove}
|
||||
onPanStart={handlePanStart}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onWheel={handleWheel}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
width={containerSize.width}
|
||||
zoom={zoom}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation controls */}
|
||||
{containerSize.width > 0 && (
|
||||
<NavigationControls
|
||||
onCenter={handleCenter}
|
||||
onZoomIn={() =>
|
||||
zoomIn(containerSize.width / 2, containerSize.height / 2)
|
||||
}
|
||||
onZoomOut={() =>
|
||||
zoomOut(containerSize.width / 2, containerSize.height / 2)
|
||||
}
|
||||
onAutoFit={handleAutoFit}
|
||||
nodes={nodes}
|
||||
className="absolute bottom-4 left-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import type { GraphNode } from "./types";
|
||||
|
||||
interface NavigationControlsProps {
|
||||
onCenter: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onAutoFit: () => void;
|
||||
nodes: GraphNode[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NavigationControls = memo<NavigationControlsProps>(
|
||||
({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => {
|
||||
if (nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAutoFit}
|
||||
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
|
||||
title="Auto-fit graph to viewport"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCenter}
|
||||
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
|
||||
title="Center view on graph"
|
||||
>
|
||||
Center
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomIn}
|
||||
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-t-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16 border-b-0"
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomOut}
|
||||
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-b-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NavigationControls.displayName = "NavigationControls";
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@lib/utils";
|
||||
import { Badge } from "@ui/components/badge";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { GlassMenuEffect } from "@ui/other/glass-effect";
|
||||
import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
import {
|
||||
GoogleDocs,
|
||||
GoogleDrive,
|
||||
GoogleSheets,
|
||||
GoogleSlides,
|
||||
MicrosoftExcel,
|
||||
MicrosoftOneNote,
|
||||
MicrosoftPowerpoint,
|
||||
MicrosoftWord,
|
||||
NotionDoc,
|
||||
OneDrive,
|
||||
PDF,
|
||||
} from "../assets/icons";
|
||||
import { HeadingH3Bold } from "../text/heading/heading-h3-bold";
|
||||
import type {
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
NodeDetailPanelProps,
|
||||
} from "./types";
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
// Special case for PDF
|
||||
if (type.toLowerCase() === "pdf") return "PDF";
|
||||
|
||||
// Replace underscores with spaces and capitalize each word
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const getDocumentIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5 text-slate-300" };
|
||||
|
||||
switch (type) {
|
||||
case "google_doc":
|
||||
return <GoogleDocs {...iconProps} />;
|
||||
case "google_sheet":
|
||||
return <GoogleSheets {...iconProps} />;
|
||||
case "google_slide":
|
||||
return <GoogleSlides {...iconProps} />;
|
||||
case "google_drive":
|
||||
return <GoogleDrive {...iconProps} />;
|
||||
case "notion":
|
||||
case "notion_doc":
|
||||
return <NotionDoc {...iconProps} />;
|
||||
case "word":
|
||||
case "microsoft_word":
|
||||
return <MicrosoftWord {...iconProps} />;
|
||||
case "excel":
|
||||
case "microsoft_excel":
|
||||
return <MicrosoftExcel {...iconProps} />;
|
||||
case "powerpoint":
|
||||
case "microsoft_powerpoint":
|
||||
return <MicrosoftPowerpoint {...iconProps} />;
|
||||
case "onenote":
|
||||
case "microsoft_onenote":
|
||||
return <MicrosoftOneNote {...iconProps} />;
|
||||
case "onedrive":
|
||||
return <OneDrive {...iconProps} />;
|
||||
case "pdf":
|
||||
return <PDF {...iconProps} />;
|
||||
default:
|
||||
return <FileText {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const NodeDetailPanel = memo<NodeDetailPanelProps>(
|
||||
({ node, onClose, variant = "console" }) => {
|
||||
// Use explicit classes that Tailwind can detect
|
||||
const getPositioningClasses = () => {
|
||||
// Both variants use the same positioning for nodeDetail
|
||||
return "top-4 right-4";
|
||||
};
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const isDocument = node.type === "document";
|
||||
const data = node.data;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn(
|
||||
"absolute w-80 rounded-xl overflow-hidden z-20 max-h-[80vh]",
|
||||
getPositioningClasses(),
|
||||
)}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="relative z-10 p-4 overflow-y-auto max-h-[80vh]"
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ delay: 0.05, duration: 0.15 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDocument ? (
|
||||
getDocumentIcon((data as DocumentWithMemories).type)
|
||||
) : (
|
||||
<Brain className="w-5 h-5 text-blue-400" />
|
||||
)}
|
||||
<HeadingH3Bold className="text-slate-100">
|
||||
{isDocument ? "Document" : "Memory"}
|
||||
</HeadingH3Bold>
|
||||
</div>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-slate-300 hover:text-slate-100"
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isDocument ? (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-wide">
|
||||
Title
|
||||
</span>
|
||||
<p className="text-sm text-slate-200 mt-1">
|
||||
{(data as DocumentWithMemories).title ||
|
||||
"Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(data as DocumentWithMemories).summary && (
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-wide">
|
||||
Summary
|
||||
</span>
|
||||
<p className="text-sm text-slate-300 mt-1 line-clamp-3">
|
||||
{(data as DocumentWithMemories).summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-wide">
|
||||
Type
|
||||
</span>
|
||||
<p className="text-sm text-slate-200 mt-1">
|
||||
{formatDocumentType((data as DocumentWithMemories).type)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-wide">
|
||||
Memory Count
|
||||
</span>
|
||||
<p className="text-sm text-slate-200 mt-1">
|
||||
{(data as DocumentWithMemories).memoryEntries.length}{" "}
|
||||
memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{((data as DocumentWithMemories).url ||
|
||||
(data as DocumentWithMemories).customId) && (
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-wide">
|
||||
URL
|
||||
</span>
|
||||
<a
|
||||
className="text-sm text-indigo-400 hover:text-indigo-300 mt-1 flex items-center gap-1"
|
||||
href={(() => {
|
||||
const doc = data as DocumentWithMemories;
|
||||
if (doc.type === "google_doc" && doc.customId) {
|
||||
return `https://docs.google.com/document/d/${doc.customId}`;
|
||||
}
|
||||
if (doc.type === "google_sheet" && doc.customId) {
|
||||
return `https://docs.google.com/spreadsheets/d/${doc.customId}`;
|
||||
}
|
||||
if (doc.type === "google_slide" && doc.customId) {
|
||||
return `https://docs.google.com/presentation/d/${doc.customId}`;
|
||||
}
|
||||
return doc.url ?? undefined;
|
||||
})()}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View Document
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-wide">
|
||||
Memory
|
||||
</span>
|
||||
<p className="text-sm text-slate-200 mt-1">
|
||||
{(data as MemoryEntry).memory}
|
||||
</p>
|
||||
{(data as MemoryEntry).isForgotten && (
|
||||
<Badge className="mt-2" variant="destructive">
|
||||
Forgotten
|
||||
</Badge>
|
||||
)}
|
||||
{(data as MemoryEntry).forgetAfter && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Expires:{" "}
|
||||
{(data as MemoryEntry).forgetAfter
|
||||
? new Date(
|
||||
(data as MemoryEntry).forgetAfter!,
|
||||
).toLocaleDateString()
|
||||
: ""}{" "}
|
||||
{"forgetReason" in data &&
|
||||
data.forgetReason &&
|
||||
`- ${data.forgetReason}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-wide">
|
||||
Space
|
||||
</span>
|
||||
<p className="text-sm text-slate-200 mt-1">
|
||||
{(data as MemoryEntry).spaceId || "Default"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-slate-700/50">
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(data.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="w-3 h-3" />
|
||||
{node.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NodeDetailPanel.displayName = "NodeDetailPanel";
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@lib/utils";
|
||||
import { Badge } from "@ui/components/badge";
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import type { SpacesDropdownProps } from "./types";
|
||||
|
||||
export const SpacesDropdown = memo<SpacesDropdownProps>(
|
||||
({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const totalMemories = Object.values(spaceMemoryCounts).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-xl border-2 border-solid border-transparent",
|
||||
"[background-image:linear-gradient(#1a1f29,_#1a1f29),_linear-gradient(150.262deg,_#A4E8F5_0%,_#267FFA_26%,_#464646_49%,_#747474_70%,_#A4E8F5_100%)]",
|
||||
"[background-origin:border-box] [background-clip:padding-box,_border-box]",
|
||||
"shadow-[inset_0px_2px_1px_rgba(84,84,84,0.15)] backdrop-blur-md",
|
||||
"transition-all duration-200 hover:shadow-[inset_0px_2px_1px_rgba(84,84,84,0.25)]",
|
||||
"cursor-pointer min-w-60",
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
type="button"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-slate-300" />
|
||||
<div className="flex-1 text-left">
|
||||
<span className="text-sm text-slate-200 font-medium">
|
||||
{selectedSpace === "all"
|
||||
? "All Spaces"
|
||||
: selectedSpace || "Select space"}
|
||||
</span>
|
||||
<div className="text-xs text-slate-400">
|
||||
{selectedSpace === "all"
|
||||
? `${totalMemories} total memories`
|
||||
: `${spaceMemoryCounts[selectedSpace] || 0} memories`}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-4 h-4 text-slate-300 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-slate-900/95 backdrop-blur-md border border-slate-700/40 rounded-xl shadow-xl z-20 overflow-hidden">
|
||||
<div className="p-1">
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
|
||||
selectedSpace === "all"
|
||||
? "bg-blue-500/20 text-blue-300"
|
||||
: "text-slate-200 hover:bg-slate-700/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onSpaceChange("all");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-sm">All Spaces</span>
|
||||
<Badge className="bg-slate-700/50 text-slate-300 text-xs">
|
||||
{totalMemories}
|
||||
</Badge>
|
||||
</button>
|
||||
{availableSpaces.map((space) => (
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
|
||||
selectedSpace === space
|
||||
? "bg-blue-500/20 text-blue-300"
|
||||
: "text-slate-200 hover:bg-slate-700/50",
|
||||
)}
|
||||
key={space}
|
||||
onClick={() => {
|
||||
onSpaceChange(space);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-sm truncate flex-1">{space}</span>
|
||||
<Badge className="bg-slate-700/50 text-slate-300 text-xs ml-2">
|
||||
{spaceMemoryCounts[space] || 0}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SpacesDropdown.displayName = "SpacesDropdown";
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type DocumentsResponse = z.infer<
|
||||
typeof DocumentsWithMemoriesResponseSchema
|
||||
>;
|
||||
export type DocumentWithMemories = DocumentsResponse["documents"][0];
|
||||
export type MemoryEntry = DocumentWithMemories["memoryEntries"][0];
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
type: "document" | "memory";
|
||||
x: number;
|
||||
y: number;
|
||||
data: DocumentWithMemories | MemoryEntry;
|
||||
size: number;
|
||||
color: string;
|
||||
isHovered: boolean;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export type MemoryRelation = "updates" | "extends" | "derives";
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
similarity: number;
|
||||
visualProps: {
|
||||
opacity: number;
|
||||
thickness: number;
|
||||
glow: number;
|
||||
pulseDuration: number;
|
||||
};
|
||||
color: string;
|
||||
edgeType: "doc-memory" | "doc-doc" | "version";
|
||||
relationType?: MemoryRelation;
|
||||
}
|
||||
|
||||
export interface SpacesDropdownProps {
|
||||
selectedSpace: string;
|
||||
availableSpaces: string[];
|
||||
spaceMemoryCounts: Record<string, number>;
|
||||
onSpaceChange: (space: string) => void;
|
||||
}
|
||||
|
||||
export interface NodeDetailPanelProps {
|
||||
node: GraphNode | null;
|
||||
onClose: () => void;
|
||||
variant?: "console" | "consumer";
|
||||
}
|
||||
|
||||
export interface GraphCanvasProps {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onNodeHover: (nodeId: string | null) => void;
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void;
|
||||
onNodeDragMove: (e: React.MouseEvent) => void;
|
||||
onNodeDragEnd: () => void;
|
||||
onPanStart: (e: React.MouseEvent) => void;
|
||||
onPanMove: (e: React.MouseEvent) => void;
|
||||
onPanEnd: () => void;
|
||||
onWheel: (e: React.WheelEvent) => void;
|
||||
onDoubleClick: (e: React.MouseEvent) => void;
|
||||
onTouchStart?: (e: React.TouchEvent) => void;
|
||||
onTouchMove?: (e: React.TouchEvent) => void;
|
||||
onTouchEnd?: (e: React.TouchEvent) => void;
|
||||
draggingNodeId: string | null;
|
||||
// Optional list of document IDs (customId or internal id) to highlight
|
||||
highlightDocumentIds?: string[];
|
||||
}
|
||||
|
||||
export interface MemoryGraphProps {
|
||||
children?: React.ReactNode;
|
||||
documents: DocumentWithMemories[];
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
error: Error | null;
|
||||
totalLoaded: number;
|
||||
hasMore: boolean;
|
||||
loadMoreDocuments: () => Promise<void>;
|
||||
// App-specific props
|
||||
showSpacesSelector?: boolean; // true for console, false for consumer
|
||||
variant?: "console" | "consumer"; // for different positioning and styling
|
||||
legendId?: string; // Optional ID for the legend component
|
||||
// Optional document highlight list (document custom IDs)
|
||||
highlightDocumentIds?: string[];
|
||||
// Whether highlights are currently visible (e.g., chat open)
|
||||
highlightsVisible?: boolean;
|
||||
// Pixels occluded on the right side of the viewport (e.g., chat panel)
|
||||
occludedRightPx?: number;
|
||||
// Whether to auto-load more documents based on viewport visibility
|
||||
autoLoadOnViewport?: boolean;
|
||||
}
|
||||
|
||||
export interface LegendProps {
|
||||
variant?: "console" | "consumer";
|
||||
nodes?: GraphNode[];
|
||||
edges?: GraphEdge[];
|
||||
isLoading?: boolean;
|
||||
hoveredNode?: string | null;
|
||||
}
|
||||
|
||||
export interface LoadingIndicatorProps {
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
totalLoaded: number;
|
||||
variant?: "console" | "consumer";
|
||||
}
|
||||
|
||||
export interface ControlsProps {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
variant?: "console" | "consumer";
|
||||
}
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { signIn } from "@lib/auth";
|
||||
import { usePostHog } from "@lib/posthog";
|
||||
import { LogoFull } from "@repo/ui/assets/Logo";
|
||||
import { TextSeparator } from "@repo/ui/components/text-separator";
|
||||
import { ExternalAuthButton } from "@ui/button/external-auth";
|
||||
import { Badge } from "@ui/components/badge";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@ui/components/carousel";
|
||||
import { LabeledInput } from "@ui/input/labeled-input";
|
||||
import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium";
|
||||
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium";
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular";
|
||||
import { Title1Bold } from "@ui/text/title/title-1-bold";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function LoginPage({
|
||||
heroText = "The unified memory API for the AI era.",
|
||||
texts = [
|
||||
"Stop building retrieval from scratch.",
|
||||
"Trusted by Open Source, enterprise and developers.",
|
||||
],
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingEmail, setIsLoadingEmail] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const posthog = usePostHog();
|
||||
|
||||
const params = useSearchParams();
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect");
|
||||
|
||||
// Create callback URL that includes redirect parameter if provided
|
||||
const getCallbackURL = () => {
|
||||
const origin = window.location.origin;
|
||||
let finalUrl: URL;
|
||||
|
||||
if (redirectUrl) {
|
||||
try {
|
||||
finalUrl = new URL(redirectUrl, origin);
|
||||
} catch {
|
||||
finalUrl = new URL(origin);
|
||||
}
|
||||
} else {
|
||||
finalUrl = new URL(origin);
|
||||
}
|
||||
|
||||
finalUrl.searchParams.set("extension-auth-success", "true");
|
||||
return finalUrl.toString();
|
||||
};
|
||||
|
||||
// Load last used method from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMethod = localStorage.getItem("supermemory-last-login-method");
|
||||
setLastUsedMethod(savedMethod);
|
||||
}, []);
|
||||
|
||||
// Record the pending login method (will be committed after successful auth)
|
||||
function setPendingLoginMethod(method: string) {
|
||||
try {
|
||||
localStorage.setItem("supermemory-pending-login-method", method);
|
||||
localStorage.setItem(
|
||||
"supermemory-pending-login-timestamp",
|
||||
String(Date.now()),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("load failed") ||
|
||||
message.includes("networkerror") ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("network request failed")
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (isNetworkError(error)) {
|
||||
return "Network error. Please check your connection and try again.";
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
|
||||
// If we land back on this page with an error, clear any pending marker
|
||||
useEffect(() => {
|
||||
if (params.get("error")) {
|
||||
try {
|
||||
localStorage.removeItem("supermemory-pending-login-method");
|
||||
localStorage.removeItem("supermemory-pending-login-timestamp");
|
||||
} catch {}
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setIsLoadingEmail(true);
|
||||
setError(null);
|
||||
|
||||
// Track login attempt
|
||||
posthog.capture("login_attempt", {
|
||||
method: "magic_link",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
});
|
||||
|
||||
try {
|
||||
await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
});
|
||||
setSubmittedEmail(email);
|
||||
setPendingLoginMethod("magic_link");
|
||||
// Track successful magic link send
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
// Track login failure
|
||||
posthog.capture("login_failed", {
|
||||
method: "magic_link",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
is_network_error: isNetworkError(error),
|
||||
});
|
||||
|
||||
setError(getErrorMessage(error));
|
||||
setIsLoading(false);
|
||||
setIsLoadingEmail(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsLoadingEmail(false);
|
||||
};
|
||||
|
||||
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const token = formData.get("token") as string;
|
||||
const callbackURL = getCallbackURL();
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col lg:grid lg:grid-cols-12 items-center justify-center p-4 sm:p-6 md:p-8 lg:px-[5rem] lg:py-[3.125rem] gap-6 lg:gap-[5rem] max-w-[400rem] mx-auto">
|
||||
<Carousel
|
||||
className="hidden lg:block lg:col-span-6"
|
||||
opts={{
|
||||
loop: true,
|
||||
}}
|
||||
plugins={[Autoplay({ delay: 5000 })]}
|
||||
>
|
||||
<CarouselContent>
|
||||
<CarouselItem className="relative">
|
||||
<Image
|
||||
alt="supermemory abstract 2d"
|
||||
height={600}
|
||||
src="/images/login-carousel-1.png"
|
||||
width={600}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-6 lg:p-12">
|
||||
<Title1Bold className="text-white mb-2 leading-tight">
|
||||
{texts[0]}
|
||||
</Title1Bold>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem className="relative">
|
||||
<Image
|
||||
alt="supermemory abstract 3d"
|
||||
height={600}
|
||||
src="/images/login-carousel-2.png"
|
||||
width={600}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-6 lg:p-12">
|
||||
<Title1Bold className="text-white mb-2 leading-tight">
|
||||
{texts[1]}
|
||||
</Title1Bold>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
{submittedEmail ? (
|
||||
<div className="w-full max-w-md lg:max-w-none lg:col-span-5 flex flex-col gap-4 lg:gap-6 min-h-2/3 ">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left">
|
||||
<Title1Bold className="text-foreground">Almost there!</Title1Bold>
|
||||
<HeadingH3Medium className="text-muted-foreground">
|
||||
Click the magic link we've sent to{" "}
|
||||
<span className="text-foreground">{submittedEmail}</span>.
|
||||
</HeadingH3Medium>
|
||||
</div>
|
||||
|
||||
<TextSeparator text="OR" />
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 lg:gap-6"
|
||||
onSubmit={handleSubmitToken}
|
||||
>
|
||||
<LabeledInput
|
||||
inputPlaceholder="your temporary login code"
|
||||
inputProps={{
|
||||
name: "token",
|
||||
required: true,
|
||||
disabled: isLoading,
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
}}
|
||||
inputType="text"
|
||||
label="Enter code"
|
||||
/>
|
||||
|
||||
<Button disabled={isLoading} id="verify-token" type="submit">
|
||||
Verify Token
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-md lg:max-w-none lg:col-span-5 flex flex-col gap-4 lg:gap-6 min-h-2/3 ">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left md:mb-12">
|
||||
<Title1Bold className="text-foreground flex flex-col justify-center md:justify-start md:flex-row items-center gap-3">
|
||||
<span className="block md:hidden">Welcome to </span>{" "}
|
||||
<LogoFull className="h-8" />
|
||||
</Title1Bold>
|
||||
<HeadingH1Medium className="text-muted-foreground">
|
||||
{heroText}
|
||||
</HeadingH1Medium>
|
||||
</div>
|
||||
|
||||
{params.get("error") && (
|
||||
<div className="text-red-500">
|
||||
Error: {params.get("error")}. Please try again!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
<LabeledInput
|
||||
error={error}
|
||||
inputPlaceholder="your@email.com"
|
||||
inputProps={{
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: isLoading,
|
||||
id: "email",
|
||||
onChange: (e) => {
|
||||
setEmail(e.target.value);
|
||||
error && setError(null);
|
||||
},
|
||||
required: true,
|
||||
value: email,
|
||||
}}
|
||||
inputType="email"
|
||||
label="Email"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Button className="w-full" disabled={isLoading} type="submit">
|
||||
{isLoadingEmail
|
||||
? "Sending login link..."
|
||||
: "Log in to supermemory"}
|
||||
</Button>
|
||||
{lastUsedMethod === "magic_link" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<TextSeparator text="OR" />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 lg:gap-4">
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
|
||||
<div className="relative flex-grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-5 sm:h-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google</title>
|
||||
<path
|
||||
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C16.827 16.5433 14.6115 18.2148 12 18.2148C8.6865 18.2148 6 15.5283 6 12.2148C6 8.90134 8.6865 6.21484 12 6.21484C13.5295 6.21484 14.921 6.79184 15.9805 7.73434L18.809 4.90584C17.023 3.24134 14.634 2.21484 12 2.21484C6.4775 2.21484 2 6.69234 2 12.2148C2 17.7373 6.4775 22.2148 12 22.2148C17.5225 22.2148 22 17.7373 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
|
||||
fill="#FFC107"
|
||||
/>
|
||||
<path
|
||||
d="M3.15234 7.56034L6.43784 9.96984C7.32684 7.76884 9.47984 6.21484 11.9993 6.21484C13.5288 6.21484 14.9203 6.79184 15.9798 7.73434L18.8083 4.90584C17.0223 3.24134 14.6333 2.21484 11.9993 2.21484C8.15834 2.21484 4.82734 4.38334 3.15234 7.56034Z"
|
||||
fill="#FF3D00"
|
||||
/>
|
||||
<path
|
||||
d="M12.0002 22.2152C14.5832 22.2152 16.9302 21.2267 18.7047 19.6192L15.6097 17.0002C14.5721 17.7897 13.3039 18.2166 12.0002 18.2152C9.39916 18.2152 7.19066 16.5567 6.35866 14.2422L3.09766 16.7547C4.75266 19.9932 8.11366 22.2152 12.0002 22.2152Z"
|
||||
fill="#4CAF50"
|
||||
/>
|
||||
<path
|
||||
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C17.2571 15.3231 16.5467 16.2914 15.608 17.0003L15.6095 16.9993L18.7045 19.6183C18.4855 19.8173 22 17.2148 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
|
||||
fill="#1976D2"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Google"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
});
|
||||
setPendingLoginMethod("google");
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Google login error:", error);
|
||||
posthog.capture("login_failed", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
is_network_error: isNetworkError(error),
|
||||
});
|
||||
setError(getErrorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "google" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<div className="relative flex-grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-5 sm:h-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Github</title>
|
||||
<g clipPath="url(#clip0_2579_3356)">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.9635 0.214844C6.20975 0.214844 0.75 5.71484 0.75 12.5191C0.75 17.9581 4.24825 22.5621 9.10125 24.1916C9.708 24.3141 9.93025 23.9268 9.93025 23.6011C9.93025 23.3158 9.91025 22.3381 9.91025 21.3193C6.51275 22.0528 5.80525 19.8526 5.80525 19.8526C5.25925 18.4266 4.45025 18.0601 4.45025 18.0601C3.33825 17.3063 4.53125 17.3063 4.53125 17.3063C5.76475 17.3878 6.412 18.5693 6.412 18.5693C7.50375 20.4433 9.263 19.9138 9.97075 19.5878C10.0718 18.7933 10.3955 18.2433 10.7393 17.9378C8.0295 17.6526 5.1785 16.5933 5.1785 11.8671C5.1785 10.5226 5.6635 9.42259 6.432 8.56709C6.31075 8.26159 5.886 6.99834 6.5535 5.30759C6.5535 5.30759 7.58475 4.98159 9.91 6.57059C10.9055 6.30126 11.9322 6.16425 12.9635 6.16309C13.9948 6.16309 15.046 6.30584 16.0168 6.57059C18.3423 4.98159 19.3735 5.30759 19.3735 5.30759C20.041 6.99834 19.616 8.26159 19.4948 8.56709C20.2835 9.42259 20.7485 10.5226 20.7485 11.8671C20.7485 16.5933 17.8975 17.6321 15.1675 17.9378C15.6125 18.3248 15.9965 19.0581 15.9965 20.2193C15.9965 21.8693 15.9765 23.1936 15.9765 23.6008C15.9765 23.9268 16.199 24.3141 16.8055 24.1918C21.6585 22.5618 25.1568 17.9581 25.1568 12.5191C25.1768 5.71484 19.697 0.214844 12.9635 0.214844Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2579_3356">
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.214844)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Github"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
});
|
||||
setPendingLoginMethod("github");
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("GitHub login error:", error);
|
||||
posthog.capture("login_failed", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
is_network_error: isNetworkError(error),
|
||||
});
|
||||
setError(getErrorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "github" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Label1Regular className="text-muted-foreground text-center text-xs sm:text-sm">
|
||||
By continuing, you agree to our{" "}
|
||||
<span className="inline-block">
|
||||
<a
|
||||
className="text-foreground hover:underline"
|
||||
href="https://supermemory.ai/terms/"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
className="text-foreground hover:underline"
|
||||
href="https://supermemory.ai/privacy/"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</Label1Regular>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { z } from "zod"
|
||||
import { ConnectionProviderEnum } from "./schemas"
|
||||
|
||||
export const providers = ConnectionProviderEnum
|
||||
export type Provider = z.infer<typeof providers>
|
||||
|
||||
const BaseMetadataSchema = <T extends z.ZodTypeAny>(provider: T) =>
|
||||
z.object({
|
||||
provider,
|
||||
})
|
||||
|
||||
export const NotionMetadataSchema = BaseMetadataSchema(
|
||||
z.literal("notion"),
|
||||
).extend({
|
||||
email: z.string().email().optional(),
|
||||
ownerId: z.string(),
|
||||
workspaceIcon: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
workspaceName: z.string(),
|
||||
})
|
||||
export type NotionMetadata = z.infer<typeof NotionMetadataSchema>
|
||||
|
||||
export const GoogleDriveMetadataSchema = BaseMetadataSchema(
|
||||
z.literal("google-drive"),
|
||||
).extend({
|
||||
pageToken: z.number(),
|
||||
webhookChannelId: z.string().optional(),
|
||||
webhookExpiration: z.number().optional(),
|
||||
webhookResourceId: z.string().optional(),
|
||||
})
|
||||
export type GoogleDriveMetadata = z.infer<typeof GoogleDriveMetadataSchema>
|
||||
|
||||
export const OneDriveMetadataSchema = BaseMetadataSchema(
|
||||
z.literal("onedrive"),
|
||||
).extend({
|
||||
deltaLink: z.string().optional(),
|
||||
lastRenewalCheck: z.number().optional(),
|
||||
webhookClientState: z.string().optional(),
|
||||
webhookExpiration: z.number().optional(),
|
||||
webhookSubscriptionId: z.string().optional(),
|
||||
})
|
||||
export type OneDriveMetadata = z.infer<typeof OneDriveMetadataSchema>
|
||||
|
||||
export const ConnectionMetadataSchema = z.discriminatedUnion("provider", [
|
||||
NotionMetadataSchema,
|
||||
GoogleDriveMetadataSchema,
|
||||
OneDriveMetadataSchema,
|
||||
])
|
||||
export type ConnectionMetadata<T extends Provider> = T extends "notion"
|
||||
? NotionMetadata
|
||||
: T extends "google-drive"
|
||||
? GoogleDriveMetadata
|
||||
: T extends "onedrive"
|
||||
? OneDriveMetadata
|
||||
: never
|
||||
|
||||
export function isNotionMetadata(
|
||||
metadata: unknown,
|
||||
): metadata is NotionMetadata {
|
||||
return NotionMetadataSchema.safeParse(metadata).success
|
||||
}
|
||||
|
||||
export function isGoogleDriveMetadata(
|
||||
metadata: unknown,
|
||||
): metadata is GoogleDriveMetadata {
|
||||
return GoogleDriveMetadataSchema.safeParse(metadata).success
|
||||
}
|
||||
|
||||
export function isOneDriveMetadata(
|
||||
metadata: unknown,
|
||||
): metadata is OneDriveMetadata {
|
||||
return OneDriveMetadataSchema.safeParse(metadata).success
|
||||
}
|
||||
|
||||
export const ConnectionStateSchema = z.object({
|
||||
createdAt: z.number(),
|
||||
org: z.string(),
|
||||
provider: providers,
|
||||
userId: z.string(),
|
||||
})
|
||||
export type ConnectionState = z.infer<typeof ConnectionStateSchema>
|
||||
|
||||
export const TokenDataSchema = z.object({
|
||||
// Only used for Notion connections since they don't support refresh tokens
|
||||
accessToken: z.string().optional(),
|
||||
createdAt: z.number(),
|
||||
documentLimit: z.number().optional(),
|
||||
email: z.string().email().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
metadata: ConnectionMetadataSchema.optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
export type TokenData = z.infer<typeof TokenDataSchema>
|
||||
|
||||
export const NotionTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
bot_id: z.string().optional(),
|
||||
duplicated_template_id: z.string().nullable().optional(),
|
||||
owner: z.union([
|
||||
z.object({
|
||||
type: z.literal("user"),
|
||||
user: z.object({
|
||||
avatar_url: z.string().url().optional(),
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
object: z.literal("user"),
|
||||
person: z
|
||||
.object({
|
||||
email: z.string().email().optional(),
|
||||
})
|
||||
.optional(),
|
||||
type: z.literal("person"),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("workspace"),
|
||||
workspace: z.literal(true),
|
||||
}),
|
||||
]),
|
||||
request_id: z.string().optional(),
|
||||
token_type: z.literal("bearer"),
|
||||
workspace_icon: z.string().optional(),
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string(),
|
||||
})
|
||||
export type NotionTokenResponse = z.infer<typeof NotionTokenResponseSchema>
|
||||
|
||||
export const GoogleDriveTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string(),
|
||||
token_type: z.literal("Bearer"),
|
||||
})
|
||||
export type GoogleDriveTokenResponse = z.infer<
|
||||
typeof GoogleDriveTokenResponseSchema
|
||||
>
|
||||
|
||||
export const OneDriveTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string(),
|
||||
token_type: z.literal("Bearer"),
|
||||
})
|
||||
export type OneDriveTokenResponse = z.infer<typeof OneDriveTokenResponseSchema>
|
||||
|
||||
export const NotionConfigSchema = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
endpoints: z.object({
|
||||
authorize: z.string().url(),
|
||||
token: z.string().url(),
|
||||
}),
|
||||
scopes: z.array(z.string()),
|
||||
})
|
||||
export type NotionConfig = z.infer<typeof NotionConfigSchema>
|
||||
|
||||
export const ConnectionQuerySchema = z.object({
|
||||
id: z.string(),
|
||||
redirectUrl: z.string().optional(),
|
||||
})
|
||||
|
||||
export const GoogleDrivePageTokenResponseSchema = z.object({
|
||||
startPageToken: z.union([z.string(), z.number()]),
|
||||
})
|
||||
|
||||
export const GoogleDriveWatchResponseSchema = z.object({
|
||||
expiration: z.string(),
|
||||
id: z.string(),
|
||||
resourceId: z.string(),
|
||||
})
|
||||
|
||||
export const OneDriveSubscriptionResponseSchema = z.object({
|
||||
changeType: z.string(),
|
||||
clientState: z.string(),
|
||||
expirationDateTime: z.string(),
|
||||
id: z.string(),
|
||||
notificationUrl: z.string(),
|
||||
resource: z.string(),
|
||||
})
|
||||
|
||||
export const GoogleUserInfoResponseSchema = z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
|
||||
export const MicrosoftUserInfoResponseSchema = z.object({
|
||||
mail: z.string().optional(),
|
||||
userPrincipalName: z.string().optional(),
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue