clean up unused folders and files (#903)

This commit is contained in:
Ishaan Gupta 2026-05-06 22:55:43 +05:30 committed by GitHub
parent 253d16b4b8
commit 7d762806a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 0 additions and 4680 deletions

View file

@ -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",
},
};

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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]);
}

View file

@ -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,
};
}

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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>
);
};

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";
}

View file

@ -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>
);
}

View file

@ -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(),
})