mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 16:13:19 +00:00
add spaces selector with search (#600)
relevant files to review: \- memory-graph.tsx \- spaces-dropdown.tsx \- spaces-dropdown.css.ts
This commit is contained in:
parent
47a2179208
commit
dfb0c05ab3
48 changed files with 2437 additions and 1941 deletions
|
|
@ -2,78 +2,78 @@
|
|||
// These mirror the API response types from @repo/validation/api
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
customId?: string | null;
|
||||
documentId: string;
|
||||
content: string | null;
|
||||
summary?: string | null;
|
||||
title?: string | null;
|
||||
url?: string | null;
|
||||
type?: string | null;
|
||||
metadata?: Record<string, string | number | boolean> | null;
|
||||
embedding?: number[] | null;
|
||||
embeddingModel?: string | null;
|
||||
tokenCount?: number | null;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
id: string
|
||||
customId?: string | null
|
||||
documentId: string
|
||||
content: string | null
|
||||
summary?: string | null
|
||||
title?: string | null
|
||||
url?: string | null
|
||||
type?: string | null
|
||||
metadata?: Record<string, string | number | boolean> | null
|
||||
embedding?: number[] | null
|
||||
embeddingModel?: string | null
|
||||
tokenCount?: number | null
|
||||
createdAt: string | Date
|
||||
updatedAt: string | Date
|
||||
// Fields from join relationship
|
||||
sourceAddedAt?: Date | null;
|
||||
sourceRelevanceScore?: number | null;
|
||||
sourceMetadata?: Record<string, unknown> | null;
|
||||
spaceContainerTag?: string | null;
|
||||
sourceAddedAt?: Date | null
|
||||
sourceRelevanceScore?: number | null
|
||||
sourceMetadata?: Record<string, unknown> | null
|
||||
spaceContainerTag?: string | null
|
||||
// Version chain fields
|
||||
updatesMemoryId?: string | null;
|
||||
nextVersionId?: string | null;
|
||||
relation?: "updates" | "extends" | "derives" | null;
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
relation?: "updates" | "extends" | "derives" | null
|
||||
// Memory status fields
|
||||
isForgotten?: boolean;
|
||||
forgetAfter?: Date | string | null;
|
||||
isLatest?: boolean;
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: Date | string | null
|
||||
isLatest?: boolean
|
||||
// Space/container fields
|
||||
spaceId?: string | null;
|
||||
spaceId?: string | null
|
||||
// Legacy fields
|
||||
memory?: string | null;
|
||||
memory?: string | null
|
||||
memoryRelations?: Array<{
|
||||
relationType: "updates" | "extends" | "derives";
|
||||
targetMemoryId: string;
|
||||
}> | null;
|
||||
parentMemoryId?: string | null;
|
||||
relationType: "updates" | "extends" | "derives"
|
||||
targetMemoryId: string
|
||||
}> | null
|
||||
parentMemoryId?: string | null
|
||||
}
|
||||
|
||||
export interface DocumentWithMemories {
|
||||
id: string;
|
||||
customId?: string | null;
|
||||
contentHash: string | null;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
connectionId?: string | null;
|
||||
title?: string | null;
|
||||
content?: string | null;
|
||||
summary?: string | null;
|
||||
url?: string | null;
|
||||
source?: string | null;
|
||||
type?: string | null;
|
||||
status: "pending" | "processing" | "done" | "failed";
|
||||
metadata?: Record<string, string | number | boolean> | null;
|
||||
processingMetadata?: Record<string, unknown> | null;
|
||||
raw?: string | null;
|
||||
tokenCount?: number | null;
|
||||
wordCount?: number | null;
|
||||
chunkCount?: number | null;
|
||||
averageChunkSize?: number | null;
|
||||
summaryEmbedding?: number[] | null;
|
||||
summaryEmbeddingModel?: string | null;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
memoryEntries: MemoryEntry[];
|
||||
id: string
|
||||
customId?: string | null
|
||||
contentHash: string | null
|
||||
orgId: string
|
||||
userId: string
|
||||
connectionId?: string | null
|
||||
title?: string | null
|
||||
content?: string | null
|
||||
summary?: string | null
|
||||
url?: string | null
|
||||
source?: string | null
|
||||
type?: string | null
|
||||
status: "pending" | "processing" | "done" | "failed"
|
||||
metadata?: Record<string, string | number | boolean> | null
|
||||
processingMetadata?: Record<string, unknown> | null
|
||||
raw?: string | null
|
||||
tokenCount?: number | null
|
||||
wordCount?: number | null
|
||||
chunkCount?: number | null
|
||||
averageChunkSize?: number | null
|
||||
summaryEmbedding?: number[] | null
|
||||
summaryEmbeddingModel?: string | null
|
||||
createdAt: string | Date
|
||||
updatedAt: string | Date
|
||||
memoryEntries: MemoryEntry[]
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
documents: DocumentWithMemories[];
|
||||
documents: DocumentWithMemories[]
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const OneDrive = ({ className }: { className?: string }) => (
|
|||
fill="#28A8EA"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const GoogleDrive = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -56,7 +56,7 @@ export const GoogleDrive = ({ className }: { className?: string }) => (
|
|||
fill="#FFBA00"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const Notion = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -71,7 +71,7 @@ export const Notion = ({ className }: { className?: string }) => (
|
|||
/>
|
||||
<path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const GoogleDocs = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -85,7 +85,7 @@ export const GoogleDocs = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const GoogleSheets = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -99,7 +99,7 @@ export const GoogleSheets = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const GoogleSlides = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -113,7 +113,7 @@ export const GoogleSlides = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const NotionDoc = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -127,7 +127,7 @@ export const NotionDoc = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const MicrosoftWord = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -141,7 +141,7 @@ export const MicrosoftWord = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const MicrosoftExcel = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -155,7 +155,7 @@ export const MicrosoftExcel = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -169,7 +169,7 @@ export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const MicrosoftOneNote = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -183,7 +183,7 @@ export const MicrosoftOneNote = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const PDF = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
|
|
@ -205,4 +205,4 @@ export const PDF = ({ className }: { className?: string }) => (
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { style } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Canvas wrapper/container that fills its parent
|
||||
|
|
@ -7,4 +7,4 @@ import { style } from "@vanilla-extract/css";
|
|||
export const canvasWrapper = style({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
});
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
import { style, styleVariants, globalStyle } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { style, styleVariants, globalStyle } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Legend container base
|
||||
|
|
@ -12,7 +12,7 @@ const legendContainerBase = style({
|
|||
width: "fit-content",
|
||||
height: "fit-content",
|
||||
maxHeight: "calc(100vh - 2rem)", // Prevent overflow
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Legend container variants for positioning
|
||||
|
|
@ -59,7 +59,7 @@ export const legendContainer = styleVariants({
|
|||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Mobile size variants
|
||||
|
|
@ -72,7 +72,7 @@ export const mobileSize = styleVariants({
|
|||
width: "4rem", // w-16
|
||||
height: "3rem", // h-12
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Legend content wrapper
|
||||
|
|
@ -80,7 +80,7 @@ export const mobileSize = styleVariants({
|
|||
export const legendContent = style({
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Collapsed trigger button
|
||||
|
|
@ -99,26 +99,26 @@ export const collapsedTrigger = style({
|
|||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const collapsedContent = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[1],
|
||||
});
|
||||
})
|
||||
|
||||
export const collapsedText = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.secondary,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
});
|
||||
})
|
||||
|
||||
export const collapsedIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
color: themeContract.colors.text.muted,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Header
|
||||
|
|
@ -132,13 +132,13 @@ export const legendHeader = style({
|
|||
paddingTop: themeContract.space[3],
|
||||
paddingBottom: themeContract.space[3],
|
||||
borderBottom: "1px solid rgba(71, 85, 105, 0.5)", // slate-600/50
|
||||
});
|
||||
})
|
||||
|
||||
export const legendTitle = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
color: themeContract.colors.text.primary,
|
||||
});
|
||||
})
|
||||
|
||||
export const headerTrigger = style({
|
||||
padding: themeContract.space[1],
|
||||
|
|
@ -150,13 +150,13 @@ export const headerTrigger = style({
|
|||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const headerIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
color: themeContract.colors.text.muted,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Content sections
|
||||
|
|
@ -168,7 +168,7 @@ export const sectionsContainer = style({
|
|||
paddingRight: themeContract.space[4],
|
||||
paddingTop: themeContract.space[3],
|
||||
paddingBottom: themeContract.space[3],
|
||||
});
|
||||
})
|
||||
|
||||
export const sectionWrapper = style({
|
||||
marginTop: themeContract.space[3],
|
||||
|
|
@ -177,7 +177,7 @@ export const sectionWrapper = style({
|
|||
marginTop: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const sectionTitle = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
|
|
@ -186,36 +186,36 @@ export const sectionTitle = style({
|
|||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: themeContract.space[2],
|
||||
});
|
||||
})
|
||||
|
||||
export const itemsList = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem", // gap-1.5
|
||||
});
|
||||
})
|
||||
|
||||
export const legendItem = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
});
|
||||
})
|
||||
|
||||
export const legendIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
flexShrink: 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const legendText = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Shape styles
|
||||
*/
|
||||
export const hexagon = style({
|
||||
clipPath: "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)",
|
||||
});
|
||||
})
|
||||
|
||||
export const documentNode = style({
|
||||
width: "1rem",
|
||||
|
|
@ -224,7 +224,7 @@ export const documentNode = style({
|
|||
border: "1px solid rgba(255, 255, 255, 0.25)",
|
||||
borderRadius: themeContract.radii.sm,
|
||||
flexShrink: 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const memoryNode = style([
|
||||
hexagon,
|
||||
|
|
@ -235,14 +235,14 @@ export const memoryNode = style([
|
|||
border: "1px solid rgba(147, 197, 253, 0.35)",
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
export const memoryNodeOlder = style([
|
||||
memoryNode,
|
||||
{
|
||||
opacity: 0.4,
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
export const forgottenNode = style([
|
||||
hexagon,
|
||||
|
|
@ -254,7 +254,7 @@ export const forgottenNode = style([
|
|||
position: "relative",
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
export const forgottenIcon = style({
|
||||
position: "absolute",
|
||||
|
|
@ -265,7 +265,7 @@ export const forgottenIcon = style({
|
|||
color: "rgb(248, 113, 113)",
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
lineHeight: "1",
|
||||
});
|
||||
})
|
||||
|
||||
export const expiringNode = style([
|
||||
hexagon,
|
||||
|
|
@ -276,7 +276,7 @@ export const expiringNode = style([
|
|||
border: "2px solid rgb(245, 158, 11)",
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
export const newNode = style([
|
||||
hexagon,
|
||||
|
|
@ -288,7 +288,7 @@ export const newNode = style([
|
|||
position: "relative",
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
export const newBadge = style({
|
||||
position: "absolute",
|
||||
|
|
@ -298,28 +298,28 @@ export const newBadge = style({
|
|||
height: "0.5rem",
|
||||
backgroundColor: "rgb(16, 185, 129)",
|
||||
borderRadius: themeContract.radii.full,
|
||||
});
|
||||
})
|
||||
|
||||
export const connectionLine = style({
|
||||
width: "1rem",
|
||||
height: 0,
|
||||
borderTop: "1px solid rgb(148, 163, 184)",
|
||||
flexShrink: 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const similarityLine = style({
|
||||
width: "1rem",
|
||||
height: 0,
|
||||
borderTop: "2px dashed rgb(148, 163, 184)",
|
||||
flexShrink: 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const relationLine = style({
|
||||
width: "1rem",
|
||||
height: 0,
|
||||
borderTop: "2px solid",
|
||||
flexShrink: 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const weakSimilarity = style({
|
||||
width: "0.75rem",
|
||||
|
|
@ -327,7 +327,7 @@ export const weakSimilarity = style({
|
|||
borderRadius: themeContract.radii.full,
|
||||
background: "rgba(148, 163, 184, 0.2)",
|
||||
flexShrink: 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const strongSimilarity = style({
|
||||
width: "0.75rem",
|
||||
|
|
@ -335,11 +335,12 @@ export const strongSimilarity = style({
|
|||
borderRadius: themeContract.radii.full,
|
||||
background: "rgba(148, 163, 184, 0.6)",
|
||||
flexShrink: 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const gradientCircle = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
background: "linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))",
|
||||
background:
|
||||
"linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))",
|
||||
borderRadius: themeContract.radii.full,
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/ui/collapsible";
|
||||
import { GlassMenuEffect } from "@/ui/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";
|
||||
import * as styles from "./legend.css";
|
||||
} from "@/ui/collapsible"
|
||||
import { GlassMenuEffect } from "@/ui/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"
|
||||
import * as styles from "./legend.css"
|
||||
|
||||
// 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=/`;
|
||||
};
|
||||
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(";");
|
||||
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);
|
||||
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;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
interface ExtendedLegendProps extends LegendProps {
|
||||
id?: string;
|
||||
nodes?: GraphNode[];
|
||||
edges?: GraphEdge[];
|
||||
isLoading?: boolean;
|
||||
id?: string
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const Legend = memo(function Legend({
|
||||
|
|
@ -48,55 +48,57 @@ export const Legend = memo(function Legend({
|
|||
edges = [],
|
||||
isLoading = false,
|
||||
}: ExtendedLegendProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
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");
|
||||
const savedState = getCookie("legendCollapsed")
|
||||
if (savedState === "true") {
|
||||
setIsExpanded(false);
|
||||
setIsExpanded(false)
|
||||
} else if (savedState === "false") {
|
||||
setIsExpanded(true);
|
||||
setIsExpanded(true)
|
||||
} else {
|
||||
// Default: collapsed on mobile, expanded on desktop
|
||||
setIsExpanded(!isMobile);
|
||||
setIsExpanded(!isMobile)
|
||||
}
|
||||
setIsInitialized(true);
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [isInitialized, isMobile]);
|
||||
}, [isInitialized, isMobile])
|
||||
|
||||
// Save to cookie when state changes
|
||||
const handleToggleExpanded = (expanded: boolean) => {
|
||||
setIsExpanded(expanded);
|
||||
setCookie("legendCollapsed", expanded ? "false" : "true");
|
||||
};
|
||||
setIsExpanded(expanded)
|
||||
setCookie("legendCollapsed", expanded ? "false" : "true")
|
||||
}
|
||||
|
||||
// Get container class based on variant and mobile state
|
||||
const getContainerClass = () => {
|
||||
if (variant === "console") {
|
||||
return isMobile ? styles.legendContainer.consoleMobile : styles.legendContainer.consoleDesktop;
|
||||
return isMobile
|
||||
? styles.legendContainer.consoleMobile
|
||||
: styles.legendContainer.consoleDesktop
|
||||
}
|
||||
return isMobile ? styles.legendContainer.consumerMobile : styles.legendContainer.consumerDesktop;
|
||||
};
|
||||
return isMobile
|
||||
? styles.legendContainer.consumerMobile
|
||||
: styles.legendContainer.consumerDesktop
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const memoryCount = nodes.filter((n) => n.type === "memory").length;
|
||||
const documentCount = nodes.filter((n) => n.type === "document").length;
|
||||
const memoryCount = nodes.filter((n) => n.type === "memory").length
|
||||
const documentCount = nodes.filter((n) => n.type === "document").length
|
||||
|
||||
const containerClass = isMobile && !isExpanded
|
||||
? `${getContainerClass()} ${styles.mobileSize.collapsed}`
|
||||
: isMobile
|
||||
? `${getContainerClass()} ${styles.mobileSize.expanded}`
|
||||
: getContainerClass();
|
||||
const containerClass =
|
||||
isMobile && !isExpanded
|
||||
? `${getContainerClass()} ${styles.mobileSize.collapsed}`
|
||||
: isMobile
|
||||
? `${getContainerClass()} ${styles.mobileSize.expanded}`
|
||||
: getContainerClass()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClass}
|
||||
id={id}
|
||||
>
|
||||
<div className={containerClass} id={id}>
|
||||
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="xl" />
|
||||
|
|
@ -128,18 +130,22 @@ export const Legend = memo(function Legend({
|
|||
{/* Stats Section */}
|
||||
{!isLoading && (
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>
|
||||
Statistics
|
||||
</div>
|
||||
<div className={styles.sectionTitle}>Statistics</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<Brain className={styles.legendIcon} style={{ color: "rgb(96, 165, 250)" }} />
|
||||
<Brain
|
||||
className={styles.legendIcon}
|
||||
style={{ color: "rgb(96, 165, 250)" }}
|
||||
/>
|
||||
<span className={styles.legendText}>
|
||||
{memoryCount} memories
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<FileText className={styles.legendIcon} style={{ color: "rgb(203, 213, 225)" }} />
|
||||
<FileText
|
||||
className={styles.legendIcon}
|
||||
style={{ color: "rgb(203, 213, 225)" }}
|
||||
/>
|
||||
<span className={styles.legendText}>
|
||||
{documentCount} documents
|
||||
</span>
|
||||
|
|
@ -156,9 +162,7 @@ export const Legend = memo(function Legend({
|
|||
|
||||
{/* Node Types */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>
|
||||
Nodes
|
||||
</div>
|
||||
<div className={styles.sectionTitle}>Nodes</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.documentNode} />
|
||||
|
|
@ -166,26 +170,26 @@ export const Legend = memo(function Legend({
|
|||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.memoryNode} />
|
||||
<span className={styles.legendText}>Memory (latest)</span>
|
||||
<span className={styles.legendText}>
|
||||
Memory (latest)
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.memoryNodeOlder} />
|
||||
<span className={styles.legendText}>Memory (older)</span>
|
||||
<span className={styles.legendText}>
|
||||
Memory (older)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>
|
||||
Status
|
||||
</div>
|
||||
<div className={styles.sectionTitle}>Status</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.forgottenNode}>
|
||||
<div className={styles.forgottenIcon}>
|
||||
✕
|
||||
</div>
|
||||
<div className={styles.forgottenIcon}>✕</div>
|
||||
</div>
|
||||
<span className={styles.legendText}>Forgotten</span>
|
||||
</div>
|
||||
|
|
@ -204,9 +208,7 @@ export const Legend = memo(function Legend({
|
|||
|
||||
{/* Connection Types */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>
|
||||
Connections
|
||||
</div>
|
||||
<div className={styles.sectionTitle}>Connections</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.connectionLine} />
|
||||
|
|
@ -214,16 +216,16 @@ export const Legend = memo(function Legend({
|
|||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.similarityLine} />
|
||||
<span className={styles.legendText}>Doc similarity</span>
|
||||
<span className={styles.legendText}>
|
||||
Doc similarity
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relation Types */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>
|
||||
Relations
|
||||
</div>
|
||||
<div className={styles.sectionTitle}>Relations</div>
|
||||
<div className={styles.itemsList}>
|
||||
{[
|
||||
["updates", colors.relations.updates],
|
||||
|
|
@ -237,7 +239,10 @@ export const Legend = memo(function Legend({
|
|||
/>
|
||||
<span
|
||||
className={styles.legendText}
|
||||
style={{ color: color, textTransform: "capitalize" }}
|
||||
style={{
|
||||
color: color,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
|
@ -248,9 +253,7 @@ export const Legend = memo(function Legend({
|
|||
|
||||
{/* Similarity Strength */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>
|
||||
Similarity
|
||||
</div>
|
||||
<div className={styles.sectionTitle}>Similarity</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.weakSimilarity} />
|
||||
|
|
@ -269,7 +272,7 @@ export const Legend = memo(function Legend({
|
|||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
|
||||
Legend.displayName = "Legend";
|
||||
Legend.displayName = "Legend"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { animations } from "../styles";
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
import { animations } from "../styles"
|
||||
|
||||
/**
|
||||
* Loading indicator container
|
||||
|
|
@ -13,7 +13,7 @@ export const loadingContainer = style({
|
|||
overflow: "hidden",
|
||||
top: "5.5rem", // Below spaces dropdown (~88px)
|
||||
left: themeContract.space[4],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Content wrapper
|
||||
|
|
@ -26,7 +26,7 @@ export const loadingContent = style({
|
|||
paddingRight: themeContract.space[4],
|
||||
paddingTop: themeContract.space[3],
|
||||
paddingBottom: themeContract.space[3],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Flex container for icon and text
|
||||
|
|
@ -35,7 +35,7 @@ export const loadingFlex = style({
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Spinning icon
|
||||
|
|
@ -45,11 +45,11 @@ export const loadingIcon = style({
|
|||
height: "1rem",
|
||||
animation: `${animations.spin} 1s linear infinite`,
|
||||
color: themeContract.colors.memory.border,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Loading text
|
||||
*/
|
||||
export const loadingText = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import type { LoadingIndicatorProps } from "@/types";
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import type { LoadingIndicatorProps } from "@/types"
|
||||
import {
|
||||
loadingContainer,
|
||||
loadingContent,
|
||||
loadingFlex,
|
||||
loadingIcon,
|
||||
loadingText,
|
||||
} from "./loading-indicator.css";
|
||||
} from "./loading-indicator.css"
|
||||
|
||||
export const LoadingIndicator = memo<LoadingIndicatorProps>(
|
||||
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
|
||||
if (!isLoading && !isLoadingMore) return null;
|
||||
if (!isLoading && !isLoadingMore) return null
|
||||
|
||||
return (
|
||||
<div className={loadingContainer}>
|
||||
|
|
@ -33,8 +33,8 @@ export const LoadingIndicator = memo<LoadingIndicatorProps>(
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
LoadingIndicator.displayName = "LoadingIndicator";
|
||||
LoadingIndicator.displayName = "LoadingIndicator"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Error state container
|
||||
|
|
@ -10,12 +10,12 @@ export const errorContainer = style({
|
|||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: themeContract.colors.background.primary,
|
||||
});
|
||||
})
|
||||
|
||||
export const errorCard = style({
|
||||
borderRadius: themeContract.radii.xl,
|
||||
overflow: "hidden",
|
||||
});
|
||||
})
|
||||
|
||||
export const errorContent = style({
|
||||
position: "relative",
|
||||
|
|
@ -25,7 +25,7 @@ export const errorContent = style({
|
|||
paddingRight: themeContract.space[6],
|
||||
paddingTop: themeContract.space[4],
|
||||
paddingBottom: themeContract.space[4],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Main graph container
|
||||
|
|
@ -37,7 +37,7 @@ export const mainContainer = style({
|
|||
borderRadius: themeContract.radii.xl,
|
||||
overflow: "hidden",
|
||||
backgroundColor: themeContract.colors.background.primary,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Spaces selector positioning
|
||||
|
|
@ -48,7 +48,7 @@ export const spacesSelectorContainer = style({
|
|||
top: themeContract.space[4],
|
||||
left: themeContract.space[4],
|
||||
zIndex: 15, // Above base elements, below loading/panels
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Graph canvas container
|
||||
|
|
@ -61,7 +61,7 @@ export const graphContainer = style({
|
|||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigation controls positioning
|
||||
|
|
@ -72,4 +72,4 @@ export const navControlsContainer = style({
|
|||
bottom: themeContract.space[4],
|
||||
left: themeContract.space[4],
|
||||
zIndex: 15, // Same level as spaces dropdown
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GraphCanvas } from "./graph-canvas";
|
||||
import { useGraphData } from "@/hooks/use-graph-data";
|
||||
import { useGraphInteractions } from "@/hooks/use-graph-interactions";
|
||||
import { injectStyles } from "@/lib/inject-styles";
|
||||
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 * as styles from "./memory-graph.css";
|
||||
import { defaultTheme } from "@/styles/theme.css";
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { GraphCanvas } from "./graph-canvas"
|
||||
import { useGraphData } from "@/hooks/use-graph-data"
|
||||
import { useGraphInteractions } from "@/hooks/use-graph-interactions"
|
||||
import { injectStyles } from "@/lib/inject-styles"
|
||||
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 * as styles from "./memory-graph.css"
|
||||
import { defaultTheme } from "@/styles/theme.css"
|
||||
|
||||
import type { MemoryGraphProps } from "@/types";
|
||||
import type { MemoryGraphProps } from "@/types"
|
||||
|
||||
export const MemoryGraph = ({
|
||||
children,
|
||||
|
|
@ -34,23 +34,45 @@ export const MemoryGraph = ({
|
|||
occludedRightPx = 0,
|
||||
autoLoadOnViewport = true,
|
||||
themeClassName,
|
||||
selectedSpace: externalSelectedSpace,
|
||||
onSpaceChange: externalOnSpaceChange,
|
||||
memoryLimit,
|
||||
isExperimental,
|
||||
}: MemoryGraphProps) => {
|
||||
// Inject styles on first render (client-side only)
|
||||
useEffect(() => {
|
||||
injectStyles();
|
||||
}, []);
|
||||
injectStyles()
|
||||
}, [])
|
||||
|
||||
// Derive totalLoaded from documents if not provided
|
||||
const effectiveTotalLoaded = totalLoaded ?? documents.length;
|
||||
const effectiveTotalLoaded = totalLoaded ?? documents.length
|
||||
// No-op for loadMoreDocuments if not provided
|
||||
const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {});
|
||||
const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {})
|
||||
// Derive showSpacesSelector from variant if not explicitly provided
|
||||
// console variant shows spaces selector, consumer variant hides it
|
||||
const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console");
|
||||
const finalShowSpacesSelector = showSpacesSelector ?? variant === "console"
|
||||
|
||||
const [selectedSpace, setSelectedSpace] = useState<string>("all");
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Internal state for controlled/uncontrolled pattern
|
||||
const [internalSelectedSpace, setInternalSelectedSpace] =
|
||||
useState<string>("all")
|
||||
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Use external state if provided, otherwise use internal state
|
||||
const selectedSpace = externalSelectedSpace ?? internalSelectedSpace
|
||||
|
||||
// Handle space change
|
||||
const handleSpaceChange = useCallback(
|
||||
(spaceId: string) => {
|
||||
if (externalOnSpaceChange) {
|
||||
externalOnSpaceChange(spaceId)
|
||||
} else {
|
||||
setInternalSelectedSpace(spaceId)
|
||||
}
|
||||
},
|
||||
[externalOnSpaceChange],
|
||||
)
|
||||
|
||||
// Create data object with pagination to satisfy type requirements
|
||||
const data = useMemo(() => {
|
||||
|
|
@ -64,8 +86,8 @@ export const MemoryGraph = ({
|
|||
totalPages: 1,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}, [documents]);
|
||||
: null
|
||||
}, [documents])
|
||||
|
||||
// Graph interactions with variant-specific settings
|
||||
const {
|
||||
|
|
@ -95,7 +117,7 @@ export const MemoryGraph = ({
|
|||
centerViewportOn,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} = useGraphInteractions(variant);
|
||||
} = useGraphInteractions(variant)
|
||||
|
||||
// Graph data
|
||||
const { nodes, edges } = useGraphData(
|
||||
|
|
@ -103,14 +125,13 @@ export const MemoryGraph = ({
|
|||
selectedSpace,
|
||||
nodePositions,
|
||||
draggingNodeId,
|
||||
);
|
||||
memoryLimit,
|
||||
)
|
||||
|
||||
// Auto-fit once per unique highlight set to show the full graph for context
|
||||
const lastFittedHighlightKeyRef = useRef<string>("");
|
||||
const lastFittedHighlightKeyRef = useRef<string>("")
|
||||
useEffect(() => {
|
||||
const highlightKey = highlightsVisible
|
||||
? highlightDocumentIds.join("|")
|
||||
: "";
|
||||
const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : ""
|
||||
if (
|
||||
highlightKey &&
|
||||
highlightKey !== lastFittedHighlightKeyRef.current &&
|
||||
|
|
@ -121,8 +142,8 @@ export const MemoryGraph = ({
|
|||
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
|
||||
occludedRightPx,
|
||||
animate: true,
|
||||
});
|
||||
lastFittedHighlightKeyRef.current = highlightKey;
|
||||
})
|
||||
lastFittedHighlightKeyRef.current = highlightKey
|
||||
}
|
||||
}, [
|
||||
highlightsVisible,
|
||||
|
|
@ -132,10 +153,10 @@ export const MemoryGraph = ({
|
|||
nodes.length,
|
||||
occludedRightPx,
|
||||
autoFitToViewport,
|
||||
]);
|
||||
])
|
||||
|
||||
// Auto-fit graph when component mounts or nodes change significantly
|
||||
const hasAutoFittedRef = useRef(false);
|
||||
const hasAutoFittedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// Only auto-fit once when we have nodes and container size
|
||||
if (
|
||||
|
|
@ -147,90 +168,85 @@ export const MemoryGraph = ({
|
|||
// 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);
|
||||
autoFitToViewport(nodes, containerSize.width, containerSize.height)
|
||||
hasAutoFittedRef.current = true
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
autoFitToViewport,
|
||||
]);
|
||||
}, [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;
|
||||
hasAutoFittedRef.current = false
|
||||
}
|
||||
}, [nodes.length]);
|
||||
}, [nodes.length])
|
||||
|
||||
// Extract unique spaces from memories and calculate counts
|
||||
const { availableSpaces, spaceMemoryCounts } = useMemo(() => {
|
||||
if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} };
|
||||
if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }
|
||||
|
||||
const spaceSet = new Set<string>();
|
||||
const counts: Record<string, number> = {};
|
||||
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;
|
||||
});
|
||||
});
|
||||
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]);
|
||||
}
|
||||
}, [data])
|
||||
|
||||
// Handle container resize
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const newWidth = containerRef.current.clientWidth;
|
||||
const newHeight = containerRef.current.clientHeight;
|
||||
|
||||
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 { width: newWidth, height: newHeight }
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
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);
|
||||
|
||||
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);
|
||||
const resizeObserver = new ResizeObserver(updateSize)
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
resizeObserver.observe(containerRef.current)
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", updateSize);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
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(nodeId, e, nodes)
|
||||
},
|
||||
[handleNodeDragStart, nodes],
|
||||
);
|
||||
)
|
||||
|
||||
// Navigation callbacks
|
||||
const handleCenter = useCallback(() => {
|
||||
|
|
@ -239,35 +255,50 @@ export const MemoryGraph = ({
|
|||
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)
|
||||
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) {
|
||||
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])
|
||||
}, [
|
||||
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]);
|
||||
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(() => {
|
||||
|
|
@ -277,7 +308,7 @@ export const MemoryGraph = ({
|
|||
!data?.documents ||
|
||||
data.documents.length === 0
|
||||
)
|
||||
return;
|
||||
return
|
||||
|
||||
// Calculate viewport bounds
|
||||
const viewportBounds = {
|
||||
|
|
@ -285,26 +316,26 @@ export const MemoryGraph = ({
|
|||
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;
|
||||
const visibilityRatio = visibleDocuments.length / data.documents.length
|
||||
if (visibilityRatio >= 0.8) {
|
||||
effectiveLoadMoreDocuments();
|
||||
effectiveLoadMoreDocuments()
|
||||
}
|
||||
}, [
|
||||
isLoadingMore,
|
||||
|
|
@ -317,35 +348,35 @@ export const MemoryGraph = ({
|
|||
containerSize.height,
|
||||
nodes,
|
||||
effectiveLoadMoreDocuments,
|
||||
]);
|
||||
])
|
||||
|
||||
// Throttled version to avoid excessive checks
|
||||
const lastLoadCheckRef = useRef(0);
|
||||
const lastLoadCheckRef = useRef(0)
|
||||
const throttledCheckAndLoadMore = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const now = Date.now()
|
||||
if (now - lastLoadCheckRef.current > 1000) {
|
||||
// Check at most once per second
|
||||
lastLoadCheckRef.current = now;
|
||||
checkAndLoadMore();
|
||||
lastLoadCheckRef.current = now
|
||||
checkAndLoadMore()
|
||||
}
|
||||
}, [checkAndLoadMore]);
|
||||
}, [checkAndLoadMore])
|
||||
|
||||
// Monitor viewport changes to trigger loading
|
||||
useEffect(() => {
|
||||
if (!autoLoadOnViewport) return;
|
||||
throttledCheckAndLoadMore();
|
||||
}, [throttledCheckAndLoadMore, autoLoadOnViewport]);
|
||||
if (!autoLoadOnViewport) return
|
||||
throttledCheckAndLoadMore()
|
||||
}, [throttledCheckAndLoadMore, autoLoadOnViewport])
|
||||
|
||||
// Initial load trigger when graph is first rendered
|
||||
useEffect(() => {
|
||||
if (!autoLoadOnViewport) return;
|
||||
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
|
||||
throttledCheckAndLoadMore()
|
||||
}, 500) // Small delay to allow initial layout
|
||||
}
|
||||
}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]);
|
||||
}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -359,17 +390,19 @@ export const MemoryGraph = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}>
|
||||
{/* Spaces selector - only shown for console */}
|
||||
{finalShowSpacesSelector && availableSpaces.length > 0 && (
|
||||
<div
|
||||
className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}
|
||||
>
|
||||
{/* Spaces selector - only shown for console variant */}
|
||||
{variant === "console" && availableSpaces.length > 0 && (
|
||||
<div className={styles.spacesSelectorContainer}>
|
||||
<SpacesDropdown
|
||||
availableSpaces={availableSpaces}
|
||||
onSpaceChange={setSelectedSpace}
|
||||
onSpaceChange={handleSpaceChange}
|
||||
selectedSpace={selectedSpace}
|
||||
spaceMemoryCounts={spaceMemoryCounts}
|
||||
/>
|
||||
|
|
@ -411,11 +444,8 @@ export const MemoryGraph = ({
|
|||
)}
|
||||
|
||||
{/* Graph container */}
|
||||
<div
|
||||
className={styles.graphContainer}
|
||||
ref={containerRef}
|
||||
>
|
||||
{(containerSize.width > 0 && containerSize.height > 0) && (
|
||||
<div className={styles.graphContainer} ref={containerRef}>
|
||||
{containerSize.width > 0 && containerSize.height > 0 && (
|
||||
<GraphCanvas
|
||||
draggingNodeId={draggingNodeId}
|
||||
edges={edges}
|
||||
|
|
@ -446,8 +476,12 @@ export const MemoryGraph = ({
|
|||
{containerSize.width > 0 && (
|
||||
<NavigationControls
|
||||
onCenter={handleCenter}
|
||||
onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)}
|
||||
onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)}
|
||||
onZoomIn={() =>
|
||||
zoomIn(containerSize.width / 2, containerSize.height / 2)
|
||||
}
|
||||
onZoomOut={() =>
|
||||
zoomOut(containerSize.width / 2, containerSize.height / 2)
|
||||
}
|
||||
onAutoFit={handleAutoFit}
|
||||
nodes={nodes}
|
||||
className={styles.navControlsContainer}
|
||||
|
|
@ -455,5 +489,5 @@ export const MemoryGraph = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Navigation controls container
|
||||
|
|
@ -8,7 +8,7 @@ export const navContainer = style({
|
|||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: themeContract.space[1],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Base button styles for navigation controls
|
||||
|
|
@ -34,12 +34,12 @@ const navButtonBase = style({
|
|||
color: "rgba(255, 255, 255, 1)",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard navigation button
|
||||
*/
|
||||
export const navButton = navButtonBase;
|
||||
export const navButton = navButtonBase
|
||||
|
||||
/**
|
||||
* Zoom controls container
|
||||
|
|
@ -47,7 +47,7 @@ export const navButton = navButtonBase;
|
|||
export const zoomContainer = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Zoom in button (top rounded)
|
||||
|
|
@ -61,7 +61,7 @@ export const zoomInButton = style([
|
|||
borderBottomRightRadius: 0,
|
||||
borderBottom: 0,
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
/**
|
||||
* Zoom out button (bottom rounded)
|
||||
|
|
@ -74,4 +74,4 @@ export const zoomOutButton = style([
|
|||
borderBottomLeftRadius: themeContract.radii.lg,
|
||||
borderBottomRightRadius: themeContract.radii.lg,
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { memo } from "react";
|
||||
import type { GraphNode } from "@/types";
|
||||
import { memo } from "react"
|
||||
import type { GraphNode } from "@/types"
|
||||
import {
|
||||
navContainer,
|
||||
navButton,
|
||||
zoomContainer,
|
||||
zoomInButton,
|
||||
zoomOutButton,
|
||||
} from "./navigation-controls.css";
|
||||
} from "./navigation-controls.css"
|
||||
|
||||
interface NavigationControlsProps {
|
||||
onCenter: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onAutoFit: () => void;
|
||||
nodes: GraphNode[];
|
||||
className?: string;
|
||||
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 null
|
||||
}
|
||||
|
||||
const containerClassName = className
|
||||
? `${navContainer} ${className}`
|
||||
: navContainer;
|
||||
: navContainer
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
|
|
@ -66,8 +66,8 @@ export const NavigationControls = memo<NavigationControlsProps>(
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
NavigationControls.displayName = "NavigationControls";
|
||||
NavigationControls.displayName = "NavigationControls"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Main container (positioned absolutely)
|
||||
|
|
@ -16,8 +16,9 @@ export const container = style({
|
|||
right: themeContract.space[4],
|
||||
|
||||
// Add shadow for depth
|
||||
boxShadow: "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
|
||||
});
|
||||
boxShadow:
|
||||
"0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
|
||||
})
|
||||
|
||||
/**
|
||||
* Content wrapper with scrolling
|
||||
|
|
@ -28,7 +29,7 @@ export const content = style({
|
|||
padding: themeContract.space[4],
|
||||
overflowY: "auto",
|
||||
maxHeight: "80vh",
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Header section
|
||||
|
|
@ -38,25 +39,25 @@ export const header = style({
|
|||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: themeContract.space[3],
|
||||
});
|
||||
})
|
||||
|
||||
export const headerLeft = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
});
|
||||
})
|
||||
|
||||
export const headerIcon = style({
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
color: themeContract.colors.text.secondary,
|
||||
});
|
||||
})
|
||||
|
||||
export const headerIconMemory = style({
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
color: "rgb(96, 165, 250)", // blue-400
|
||||
});
|
||||
})
|
||||
|
||||
export const closeButton = style({
|
||||
height: "32px",
|
||||
|
|
@ -69,12 +70,12 @@ export const closeButton = style({
|
|||
color: themeContract.colors.text.primary,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const closeIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Content sections
|
||||
|
|
@ -83,22 +84,22 @@ export const sections = style({
|
|||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: themeContract.space[3],
|
||||
});
|
||||
})
|
||||
|
||||
export const section = style({});
|
||||
export const section = style({})
|
||||
|
||||
export const sectionLabel = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
});
|
||||
})
|
||||
|
||||
export const sectionValue = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.secondary,
|
||||
marginTop: themeContract.space[1],
|
||||
});
|
||||
})
|
||||
|
||||
export const sectionValueTruncated = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
|
|
@ -108,7 +109,7 @@ export const sectionValueTruncated = style({
|
|||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
});
|
||||
})
|
||||
|
||||
export const link = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
|
|
@ -125,22 +126,22 @@ export const link = style({
|
|||
color: "rgb(165, 180, 252)", // indigo-300
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const linkIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
});
|
||||
})
|
||||
|
||||
export const badge = style({
|
||||
marginTop: themeContract.space[2],
|
||||
});
|
||||
})
|
||||
|
||||
export const expiryText = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
marginTop: themeContract.space[1],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Footer section (metadata)
|
||||
|
|
@ -148,7 +149,7 @@ export const expiryText = style({
|
|||
export const footer = style({
|
||||
paddingTop: themeContract.space[2],
|
||||
borderTop: "1px solid rgba(71, 85, 105, 0.5)", // slate-700/50
|
||||
});
|
||||
})
|
||||
|
||||
export const metadata = style({
|
||||
display: "flex",
|
||||
|
|
@ -156,15 +157,15 @@ export const metadata = style({
|
|||
gap: themeContract.space[4],
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
});
|
||||
})
|
||||
|
||||
export const metadataItem = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[1],
|
||||
});
|
||||
})
|
||||
|
||||
export const metadataIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/ui/badge";
|
||||
import { Button } from "@/ui/button";
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect";
|
||||
import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
import { Badge } from "@/ui/badge"
|
||||
import { Button } from "@/ui/button"
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect"
|
||||
import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { memo } from "react"
|
||||
import {
|
||||
GoogleDocs,
|
||||
GoogleDrive,
|
||||
|
|
@ -18,249 +18,233 @@ import {
|
|||
NotionDoc,
|
||||
OneDrive,
|
||||
PDF,
|
||||
} from "@/assets/icons";
|
||||
import { HeadingH3Bold } from "@/ui/heading";
|
||||
import type {
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "@/types";
|
||||
import type { NodeDetailPanelProps } from "@/types";
|
||||
import * as styles from "./node-detail-panel.css";
|
||||
} from "@/assets/icons"
|
||||
import { HeadingH3Bold } from "@/ui/heading"
|
||||
import type { DocumentWithMemories, MemoryEntry } from "@/types"
|
||||
import type { NodeDetailPanelProps } from "@/types"
|
||||
import * as styles from "./node-detail-panel.css"
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
// Special case for PDF
|
||||
if (type.toLowerCase() === "pdf") return "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(" ");
|
||||
};
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
const getDocumentIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5 text-slate-300" };
|
||||
const iconProps = { className: "w-5 h-5 text-slate-300" }
|
||||
|
||||
switch (type) {
|
||||
case "google_doc":
|
||||
return <GoogleDocs {...iconProps} />;
|
||||
return <GoogleDocs {...iconProps} />
|
||||
case "google_sheet":
|
||||
return <GoogleSheets {...iconProps} />;
|
||||
return <GoogleSheets {...iconProps} />
|
||||
case "google_slide":
|
||||
return <GoogleSlides {...iconProps} />;
|
||||
return <GoogleSlides {...iconProps} />
|
||||
case "google_drive":
|
||||
return <GoogleDrive {...iconProps} />;
|
||||
return <GoogleDrive {...iconProps} />
|
||||
case "notion":
|
||||
case "notion_doc":
|
||||
return <NotionDoc {...iconProps} />;
|
||||
return <NotionDoc {...iconProps} />
|
||||
case "word":
|
||||
case "microsoft_word":
|
||||
return <MicrosoftWord {...iconProps} />;
|
||||
return <MicrosoftWord {...iconProps} />
|
||||
case "excel":
|
||||
case "microsoft_excel":
|
||||
return <MicrosoftExcel {...iconProps} />;
|
||||
return <MicrosoftExcel {...iconProps} />
|
||||
case "powerpoint":
|
||||
case "microsoft_powerpoint":
|
||||
return <MicrosoftPowerpoint {...iconProps} />;
|
||||
return <MicrosoftPowerpoint {...iconProps} />
|
||||
case "onenote":
|
||||
case "microsoft_onenote":
|
||||
return <MicrosoftOneNote {...iconProps} />;
|
||||
return <MicrosoftOneNote {...iconProps} />
|
||||
case "onedrive":
|
||||
return <OneDrive {...iconProps} />;
|
||||
return <OneDrive {...iconProps} />
|
||||
case "pdf":
|
||||
return <PDF {...iconProps} />;
|
||||
return <PDF {...iconProps} />
|
||||
default:
|
||||
{/*@ts-ignore */}
|
||||
return <FileText {...iconProps} />;
|
||||
{
|
||||
/*@ts-ignore */
|
||||
}
|
||||
return <FileText {...iconProps} />
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const NodeDetailPanel = memo(
|
||||
function NodeDetailPanel({ node, onClose, variant = "console" }: NodeDetailPanelProps) {
|
||||
if (!node) return null;
|
||||
export const NodeDetailPanel = memo(function NodeDetailPanel({
|
||||
node,
|
||||
onClose,
|
||||
variant = "console",
|
||||
}: NodeDetailPanelProps) {
|
||||
if (!node) return null
|
||||
|
||||
const isDocument = node.type === "document";
|
||||
const data = node.data;
|
||||
const isDocument = node.type === "document"
|
||||
const data = node.data
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={styles.container}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="xl" />
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={styles.container}
|
||||
exit={{ opacity: 0 }}
|
||||
className={styles.content}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
transition={{ delay: 0.05, duration: 0.15 }}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="xl" />
|
||||
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={styles.content}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ delay: 0.05, duration: 0.15 }}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
{isDocument ? (
|
||||
getDocumentIcon((data as DocumentWithMemories).type ?? "")
|
||||
) : (
|
||||
// @ts-ignore
|
||||
<Brain className={styles.headerIconMemory} />
|
||||
)}
|
||||
<HeadingH3Bold>
|
||||
{isDocument ? "Document" : "Memory"}
|
||||
</HeadingH3Bold>
|
||||
</div>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<X className={styles.closeIcon} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sections}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
{isDocument ? (
|
||||
<>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>
|
||||
Title
|
||||
</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as DocumentWithMemories).title ||
|
||||
"Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(data as DocumentWithMemories).summary && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>
|
||||
Summary
|
||||
</span>
|
||||
<p className={styles.sectionValueTruncated}>
|
||||
{(data as DocumentWithMemories).summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>
|
||||
Type
|
||||
</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{formatDocumentType((data as DocumentWithMemories).type ?? "")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>
|
||||
Memory Count
|
||||
</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as DocumentWithMemories).memoryEntries.length}{" "}
|
||||
memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{((data as DocumentWithMemories).url ||
|
||||
(data as DocumentWithMemories).customId) && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>
|
||||
URL
|
||||
</span>
|
||||
<a
|
||||
className={styles.link}
|
||||
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"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<ExternalLink className={styles.linkIcon} />
|
||||
View Document
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
getDocumentIcon((data as DocumentWithMemories).type ?? "")
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>
|
||||
Memory
|
||||
</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as MemoryEntry).memory}
|
||||
</p>
|
||||
{(data as MemoryEntry).isForgotten && (
|
||||
<Badge className={styles.badge} variant="destructive">
|
||||
Forgotten
|
||||
</Badge>
|
||||
)}
|
||||
{(data as MemoryEntry).forgetAfter && (
|
||||
<p className={styles.expiryText}>
|
||||
Expires:{" "}
|
||||
{(data as MemoryEntry).forgetAfter
|
||||
? new Date(
|
||||
(data as MemoryEntry).forgetAfter!,
|
||||
).toLocaleDateString()
|
||||
: ""}{" "}
|
||||
{("forgetReason" in data &&
|
||||
(data as any).forgetReason
|
||||
? `- ${(data as any).forgetReason}`
|
||||
: null)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>
|
||||
Space
|
||||
</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as MemoryEntry).spaceId || "Default"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
// @ts-ignore
|
||||
<Brain className={styles.headerIconMemory} />
|
||||
)}
|
||||
<HeadingH3Bold>{isDocument ? "Document" : "Memory"}</HeadingH3Bold>
|
||||
</div>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<X className={styles.closeIcon} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.metadata}>
|
||||
<span className={styles.metadataItem}>
|
||||
{/* @ts-ignore */}
|
||||
<Calendar className={styles.metadataIcon} />
|
||||
{new Date(data.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className={styles.metadataItem}>
|
||||
{/* @ts-ignore */}
|
||||
<Hash className={styles.metadataIcon} />
|
||||
{node.id}
|
||||
</span>
|
||||
<div className={styles.sections}>
|
||||
{isDocument ? (
|
||||
<>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Title</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as DocumentWithMemories).title || "Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(data as DocumentWithMemories).summary && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Summary</span>
|
||||
<p className={styles.sectionValueTruncated}>
|
||||
{(data as DocumentWithMemories).summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Type</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{formatDocumentType(
|
||||
(data as DocumentWithMemories).type ?? "",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Memory Count</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as DocumentWithMemories).memoryEntries.length} memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{((data as DocumentWithMemories).url ||
|
||||
(data as DocumentWithMemories).customId) && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>URL</span>
|
||||
<a
|
||||
className={styles.link}
|
||||
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"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<ExternalLink className={styles.linkIcon} />
|
||||
View Document
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Memory</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as MemoryEntry).memory}
|
||||
</p>
|
||||
{(data as MemoryEntry).isForgotten && (
|
||||
<Badge className={styles.badge} variant="destructive">
|
||||
Forgotten
|
||||
</Badge>
|
||||
)}
|
||||
{(data as MemoryEntry).forgetAfter && (
|
||||
<p className={styles.expiryText}>
|
||||
Expires:{" "}
|
||||
{(data as MemoryEntry).forgetAfter
|
||||
? new Date(
|
||||
(data as MemoryEntry).forgetAfter!,
|
||||
).toLocaleDateString()
|
||||
: ""}{" "}
|
||||
{"forgetReason" in data && (data as any).forgetReason
|
||||
? `- ${(data as any).forgetReason}`
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Space</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as MemoryEntry).spaceId || "Default"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.metadata}>
|
||||
<span className={styles.metadataItem}>
|
||||
{/* @ts-ignore */}
|
||||
<Calendar className={styles.metadataIcon} />
|
||||
{new Date(data.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className={styles.metadataItem}>
|
||||
{/* @ts-ignore */}
|
||||
<Hash className={styles.metadataIcon} />
|
||||
{node.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
NodeDetailPanel.displayName = "NodeDetailPanel";
|
||||
NodeDetailPanel.displayName = "NodeDetailPanel"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { style, keyframes } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
const spin = keyframes({
|
||||
"0%": { transform: "rotate(0deg)" },
|
||||
"100%": { transform: "rotate(360deg)" },
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown container
|
||||
*/
|
||||
export const container = style({
|
||||
position: "relative",
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Main trigger button with gradient border effect
|
||||
|
|
@ -37,40 +42,40 @@ export const trigger = style({
|
|||
boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.25)",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const triggerIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
color: themeContract.colors.text.secondary,
|
||||
});
|
||||
})
|
||||
|
||||
export const triggerContent = style({
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
});
|
||||
})
|
||||
|
||||
export const triggerLabel = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.secondary,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
});
|
||||
})
|
||||
|
||||
export const triggerSubtext = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
});
|
||||
})
|
||||
|
||||
export const triggerChevron = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
color: themeContract.colors.text.secondary,
|
||||
transition: "transform 200ms ease",
|
||||
});
|
||||
})
|
||||
|
||||
export const triggerChevronOpen = style({
|
||||
transform: "rotate(180deg)",
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown menu
|
||||
|
|
@ -90,11 +95,97 @@ export const dropdown = style({
|
|||
"0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl
|
||||
zIndex: 20,
|
||||
overflow: "hidden",
|
||||
});
|
||||
})
|
||||
|
||||
export const dropdownInner = style({
|
||||
padding: themeContract.space[1],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Search container and form
|
||||
*/
|
||||
export const searchContainer = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
padding: themeContract.space[2],
|
||||
borderBottom: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40
|
||||
})
|
||||
|
||||
export const searchForm = style({
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
})
|
||||
|
||||
export const searchButton = style({
|
||||
color: themeContract.colors.text.muted,
|
||||
padding: themeContract.space[1],
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
color: themeContract.colors.text.secondary,
|
||||
},
|
||||
"&:disabled": {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const searchIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
})
|
||||
|
||||
export const searchInput = style({
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.secondary,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
|
||||
"::placeholder": {
|
||||
color: themeContract.colors.text.muted,
|
||||
},
|
||||
})
|
||||
|
||||
export const searchSpinner = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
borderRadius: "50%",
|
||||
border: "2px solid rgba(148, 163, 184, 0.3)", // slate-400 with opacity
|
||||
borderTopColor: "rgb(148, 163, 184)", // slate-400
|
||||
animation: `${spin} 1s linear infinite`,
|
||||
})
|
||||
|
||||
export const searchClearButton = style({
|
||||
color: themeContract.colors.text.muted,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
color: themeContract.colors.text.secondary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown list container
|
||||
*/
|
||||
export const dropdownList = style({
|
||||
maxHeight: "16rem", // max-h-64
|
||||
overflowY: "auto",
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown items
|
||||
|
|
@ -114,7 +205,7 @@ const dropdownItemBase = style({
|
|||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
});
|
||||
})
|
||||
|
||||
export const dropdownItem = style([
|
||||
dropdownItemBase,
|
||||
|
|
@ -127,7 +218,7 @@ export const dropdownItem = style([
|
|||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
export const dropdownItemActive = style([
|
||||
dropdownItemBase,
|
||||
|
|
@ -135,12 +226,20 @@ export const dropdownItemActive = style([
|
|||
backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20
|
||||
color: "rgb(147, 197, 253)", // blue-300
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
export const dropdownItemHighlighted = style([
|
||||
dropdownItemBase,
|
||||
{
|
||||
backgroundColor: "rgba(51, 65, 85, 0.7)", // slate-700/70
|
||||
color: themeContract.colors.text.secondary,
|
||||
},
|
||||
])
|
||||
|
||||
export const dropdownItemLabel = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
flex: 1,
|
||||
});
|
||||
})
|
||||
|
||||
export const dropdownItemLabelTruncate = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
|
|
@ -148,11 +247,24 @@ export const dropdownItemLabelTruncate = style({
|
|||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
})
|
||||
|
||||
export const dropdownItemBadge = style({
|
||||
backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50
|
||||
color: themeContract.colors.text.secondary,
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
marginLeft: themeContract.space[2],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Empty state message
|
||||
*/
|
||||
export const emptyState = style({
|
||||
paddingLeft: themeContract.space[3],
|
||||
paddingRight: themeContract.space[3],
|
||||
paddingTop: themeContract.space[2],
|
||||
paddingBottom: themeContract.space[2],
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.muted,
|
||||
textAlign: "center",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/ui/badge";
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import type { SpacesDropdownProps } from "@/types";
|
||||
import * as styles from "./spaces-dropdown.css";
|
||||
import { Badge } from "@/ui/badge"
|
||||
import { ChevronDown, Eye, Search, X } from "lucide-react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import type { SpacesDropdownProps } from "@/types"
|
||||
import * as styles from "./spaces-dropdown.css"
|
||||
|
||||
export const SpacesDropdown = memo<SpacesDropdownProps>(
|
||||
({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map())
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
|
|
@ -18,38 +22,115 @@ export const SpacesDropdown = memo<SpacesDropdownProps>(
|
|||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setIsOpen(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Clear search query and reset highlighted index when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchQuery("")
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Filter spaces based on search query (client-side)
|
||||
const filteredSpaces = searchQuery
|
||||
? availableSpaces.filter((space) =>
|
||||
space.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: availableSpaces
|
||||
|
||||
const totalMemories = Object.values(spaceMemoryCounts).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
)
|
||||
|
||||
// Total items including "Latest" option
|
||||
const totalItems = filteredSpaces.length + 1
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && highlightedIndex < totalItems) {
|
||||
const element = itemRefs.current.get(highlightedIndex)
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex, totalItems])
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) return
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0))
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1))
|
||||
break
|
||||
case "Enter":
|
||||
e.preventDefault()
|
||||
if (highlightedIndex === 0) {
|
||||
onSpaceChange("all")
|
||||
setIsOpen(false)
|
||||
} else if (
|
||||
highlightedIndex > 0 &&
|
||||
highlightedIndex <= filteredSpaces.length
|
||||
) {
|
||||
const selectedSpace = filteredSpaces[highlightedIndex - 1]
|
||||
if (selectedSpace) {
|
||||
onSpaceChange(selectedSpace)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={dropdownRef}>
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={dropdownRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<button
|
||||
className={styles.trigger}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
type="button"
|
||||
>
|
||||
{/*@ts-ignore */}
|
||||
<Eye className={styles.triggerIcon} />
|
||||
{/*@ts-ignore */}
|
||||
<Eye className={styles.triggerIcon} />
|
||||
<div className={styles.triggerContent}>
|
||||
<span className={styles.triggerLabel}>
|
||||
{selectedSpace === "all"
|
||||
? "All Spaces"
|
||||
? "Latest"
|
||||
: selectedSpace || "Select space"}
|
||||
</span>
|
||||
<div className={styles.triggerSubtext}>
|
||||
{selectedSpace === "all"
|
||||
? `${totalMemories} total memories`
|
||||
? ""
|
||||
: `${spaceMemoryCounts[selectedSpace] || 0} memories`}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,49 +143,105 @@ export const SpacesDropdown = memo<SpacesDropdownProps>(
|
|||
{isOpen && (
|
||||
<div className={styles.dropdown}>
|
||||
<div className={styles.dropdownInner}>
|
||||
<button
|
||||
className={
|
||||
selectedSpace === "all"
|
||||
? styles.dropdownItemActive
|
||||
: styles.dropdownItem
|
||||
}
|
||||
onClick={() => {
|
||||
onSpaceChange("all");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className={styles.dropdownItemLabel}>All Spaces</span>
|
||||
<Badge className={styles.dropdownItemBadge}>
|
||||
{totalMemories}
|
||||
</Badge>
|
||||
</button>
|
||||
{availableSpaces.map((space) => (
|
||||
{/* Search Input - Always show for filtering */}
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchForm}>
|
||||
{/*@ts-ignore */}
|
||||
<Search className={styles.searchIcon} />
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search spaces..."
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className={styles.searchClearButton}
|
||||
onClick={() => setSearchQuery("")}
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
{/*@ts-ignore */}
|
||||
<X className={styles.searchIcon} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spaces List */}
|
||||
<div className={styles.dropdownList}>
|
||||
{/* Always show "Latest" option */}
|
||||
<button
|
||||
className={
|
||||
selectedSpace === space
|
||||
? styles.dropdownItemActive
|
||||
: styles.dropdownItem
|
||||
}
|
||||
key={space}
|
||||
onClick={() => {
|
||||
onSpaceChange(space);
|
||||
setIsOpen(false);
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(0, el)
|
||||
}}
|
||||
className={
|
||||
selectedSpace === "all"
|
||||
? styles.dropdownItemActive
|
||||
: highlightedIndex === 0
|
||||
? styles.dropdownItemHighlighted
|
||||
: styles.dropdownItem
|
||||
}
|
||||
onClick={() => {
|
||||
onSpaceChange("all")
|
||||
setIsOpen(false)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
type="button"
|
||||
>
|
||||
<span className={styles.dropdownItemLabelTruncate}>{space}</span>
|
||||
<span className={styles.dropdownItemLabel}>Latest</span>
|
||||
<Badge className={styles.dropdownItemBadge}>
|
||||
{spaceMemoryCounts[space] || 0}
|
||||
{totalMemories}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Show all spaces, filtered by search query */}
|
||||
{filteredSpaces.length > 0
|
||||
? filteredSpaces.map((space, index) => {
|
||||
const itemIndex = index + 1
|
||||
return (
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(itemIndex, el)
|
||||
}}
|
||||
className={
|
||||
selectedSpace === space
|
||||
? styles.dropdownItemActive
|
||||
: highlightedIndex === itemIndex
|
||||
? styles.dropdownItemHighlighted
|
||||
: styles.dropdownItem
|
||||
}
|
||||
key={space}
|
||||
onClick={() => {
|
||||
onSpaceChange(space)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(itemIndex)}
|
||||
type="button"
|
||||
>
|
||||
<span className={styles.dropdownItemLabelTruncate}>
|
||||
{space}
|
||||
</span>
|
||||
<Badge className={styles.dropdownItemBadge}>
|
||||
{spaceMemoryCounts[space] || 0}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
: searchQuery && (
|
||||
<div className={styles.emptyState}>
|
||||
No spaces found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
SpacesDropdown.displayName = "SpacesDropdown";
|
||||
SpacesDropdown.displayName = "SpacesDropdown"
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export const colors = {
|
|||
extends: "rgba(16, 185, 129, 0.5)", // green
|
||||
derives: "rgba(147, 197, 253, 0.5)", // blue
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const LAYOUT_CONSTANTS = {
|
||||
centerX: 400,
|
||||
|
|
@ -57,7 +57,7 @@ export const LAYOUT_CONSTANTS = {
|
|||
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 = {
|
||||
|
|
@ -71,7 +71,7 @@ export const GRAPH_SETTINGS = {
|
|||
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 = {
|
||||
|
|
@ -97,4 +97,4 @@ export const POSITIONING = {
|
|||
viewToggle: "top-4 right-4", // Consumer has view toggle
|
||||
nodeDetail: "top-4 right-4",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import {
|
||||
calculateSemanticSimilarity,
|
||||
getConnectionVisualProps,
|
||||
getMagicalConnectionColor,
|
||||
} from "@/lib/similarity";
|
||||
import { useMemo } from "react";
|
||||
import { colors, LAYOUT_CONSTANTS } from "@/constants";
|
||||
} from "@/lib/similarity"
|
||||
import { useMemo } from "react"
|
||||
import { colors, LAYOUT_CONSTANTS } from "@/constants"
|
||||
import type {
|
||||
DocumentsResponse,
|
||||
DocumentWithMemories,
|
||||
|
|
@ -14,95 +14,106 @@ import type {
|
|||
GraphNode,
|
||||
MemoryEntry,
|
||||
MemoryRelation,
|
||||
} from "@/types";
|
||||
} from "@/types"
|
||||
|
||||
export function useGraphData(
|
||||
data: DocumentsResponse | null,
|
||||
selectedSpace: string,
|
||||
nodePositions: Map<string, { x: number; y: number }>,
|
||||
draggingNodeId: string | null,
|
||||
memoryLimit?: number,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!data?.documents) return { nodes: [], edges: [] };
|
||||
if (!data?.documents) return { nodes: [], edges: [] }
|
||||
|
||||
const allNodes: GraphNode[] = [];
|
||||
const allEdges: GraphEdge[] = [];
|
||||
const allNodes: GraphNode[] = []
|
||||
const allEdges: GraphEdge[] = []
|
||||
|
||||
// Filter documents that have memories in selected space
|
||||
// AND limit memories per document when memoryLimit is provided
|
||||
const filteredDocuments = data.documents
|
||||
.map((doc) => ({
|
||||
...doc,
|
||||
memoryEntries:
|
||||
.map((doc) => {
|
||||
let memories =
|
||||
selectedSpace === "all"
|
||||
? doc.memoryEntries
|
||||
: doc.memoryEntries.filter(
|
||||
(memory) =>
|
||||
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
|
||||
selectedSpace,
|
||||
),
|
||||
}))
|
||||
.filter((doc) => doc.memoryEntries.length > 0);
|
||||
)
|
||||
|
||||
// Apply memory limit if provided and a specific space is selected
|
||||
if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) {
|
||||
memories = memories.slice(0, memoryLimit)
|
||||
}
|
||||
|
||||
return {
|
||||
...doc,
|
||||
memoryEntries: memories,
|
||||
}
|
||||
})
|
||||
.filter((doc) => doc.memoryEntries.length > 0)
|
||||
|
||||
// Group documents by space for better clustering
|
||||
const documentsBySpace = new Map<string, typeof filteredDocuments>();
|
||||
const documentsBySpace = new Map<string, typeof filteredDocuments>()
|
||||
filteredDocuments.forEach((doc) => {
|
||||
const docSpace =
|
||||
doc.memoryEntries[0]?.spaceContainerTag ??
|
||||
doc.memoryEntries[0]?.spaceId ??
|
||||
"default";
|
||||
"default"
|
||||
if (!documentsBySpace.has(docSpace)) {
|
||||
documentsBySpace.set(docSpace, []);
|
||||
documentsBySpace.set(docSpace, [])
|
||||
}
|
||||
const spaceDocsArr = documentsBySpace.get(docSpace);
|
||||
const spaceDocsArr = documentsBySpace.get(docSpace)
|
||||
if (spaceDocsArr) {
|
||||
spaceDocsArr.push(doc);
|
||||
spaceDocsArr.push(doc)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Enhanced Layout with Space Separation
|
||||
const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
|
||||
LAYOUT_CONSTANTS;
|
||||
LAYOUT_CONSTANTS
|
||||
|
||||
/* 1. Build DOCUMENT nodes with space-aware clustering */
|
||||
const documentNodes: GraphNode[] = [];
|
||||
let spaceIndex = 0;
|
||||
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;
|
||||
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;
|
||||
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
|
||||
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;
|
||||
const positionInRing = docIndex - totalDocsInPreviousRings
|
||||
const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2
|
||||
|
||||
// Radius increases significantly with each ring
|
||||
const baseRadius = documentSpacing * 0.8;
|
||||
const baseRadius = documentSpacing * 0.8
|
||||
const radius =
|
||||
currentRing === 0
|
||||
? baseRadius
|
||||
: baseRadius + currentRing * documentSpacing * 1.2;
|
||||
: baseRadius + currentRing * documentSpacing * 1.2
|
||||
|
||||
const defaultX = spaceCenterX + Math.cos(angleInRing) * radius;
|
||||
const defaultY = spaceCenterY + Math.sin(angleInRing) * radius;
|
||||
const defaultX = spaceCenterX + Math.cos(angleInRing) * radius
|
||||
const defaultY = spaceCenterY + Math.sin(angleInRing) * radius
|
||||
|
||||
const customPos = nodePositions.get(doc.id);
|
||||
const customPos = nodePositions.get(doc.id)
|
||||
|
||||
documentNodes.push({
|
||||
id: doc.id,
|
||||
|
|
@ -114,81 +125,80 @@ export function useGraphData(
|
|||
color: colors.document.primary,
|
||||
isHovered: false,
|
||||
isDragging: draggingNodeId === doc.id,
|
||||
} satisfies GraphNode);
|
||||
});
|
||||
} satisfies GraphNode)
|
||||
})
|
||||
|
||||
spaceIndex++;
|
||||
});
|
||||
spaceIndex++
|
||||
})
|
||||
|
||||
/* 2. Gentle document collision avoidance with dampening */
|
||||
const minDocDist = LAYOUT_CONSTANTS.minDocDist;
|
||||
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;
|
||||
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";
|
||||
"default"
|
||||
const spaceB =
|
||||
(nodeB.data as DocumentWithMemories).memoryEntries[0]
|
||||
?.spaceContainerTag ??
|
||||
(nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
|
||||
"default";
|
||||
"default"
|
||||
|
||||
if (spaceA !== spaceB) return;
|
||||
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;
|
||||
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 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;
|
||||
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);
|
||||
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;
|
||||
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 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 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;
|
||||
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;
|
||||
docNode.x + Math.cos(clusterAngle) * distance + offsetX
|
||||
const defaultMemY =
|
||||
docNode.y + Math.sin(clusterAngle) * distance + offsetY;
|
||||
docNode.y + Math.sin(clusterAngle) * distance + offsetY
|
||||
|
||||
if (!memoryNodeMap.has(memoryId)) {
|
||||
const memoryNode: GraphNode = {
|
||||
|
|
@ -204,9 +214,9 @@ export function useGraphData(
|
|||
color: colors.memory.primary,
|
||||
isHovered: false,
|
||||
isDragging: draggingNodeId === memoryId,
|
||||
};
|
||||
memoryNodeMap.set(memoryId, memoryNode);
|
||||
allNodes.push(memoryNode);
|
||||
}
|
||||
memoryNodeMap.set(memoryId, memoryNode)
|
||||
allNodes.push(memoryNode)
|
||||
}
|
||||
|
||||
// Create doc-memory edge with similarity
|
||||
|
|
@ -218,23 +228,23 @@ export function useGraphData(
|
|||
visualProps: getConnectionVisualProps(1),
|
||||
color: colors.connection.memory,
|
||||
edgeType: "doc-memory",
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Build mapping of memoryId -> nodeId for version chains
|
||||
const memNodeIdMap = new Map<string, string>();
|
||||
const memNodeIdMap = new Map<string, string>()
|
||||
allNodes.forEach((n) => {
|
||||
if (n.type === "memory") {
|
||||
memNodeIdMap.set((n.data as MemoryEntry).id, n.id);
|
||||
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> = {};
|
||||
let parentRelations: Record<string, MemoryRelation> = {}
|
||||
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
|
|
@ -242,18 +252,21 @@ export function useGraphData(
|
|||
mem.memoryRelations.length > 0
|
||||
) {
|
||||
// Convert array to Record
|
||||
parentRelations = mem.memoryRelations.reduce((acc, rel) => {
|
||||
acc[rel.targetMemoryId] = rel.relationType;
|
||||
return acc;
|
||||
}, {} as Record<string, MemoryRelation>);
|
||||
parentRelations = mem.memoryRelations.reduce(
|
||||
(acc, rel) => {
|
||||
acc[rel.targetMemoryId] = rel.relationType
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, MemoryRelation>,
|
||||
)
|
||||
} 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);
|
||||
const fromId = memNodeIdMap.get(pid)
|
||||
const toId = memNodeIdMap.get(mem.id)
|
||||
if (fromId && toId) {
|
||||
allEdges.push({
|
||||
id: `version-${fromId}-${toId}`,
|
||||
|
|
@ -270,25 +283,25 @@ export function useGraphData(
|
|||
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;
|
||||
const docI = filteredDocuments[i]
|
||||
if (!docI) continue
|
||||
|
||||
for (let j = i + 1; j < filteredDocuments.length; j++) {
|
||||
const docJ = filteredDocuments[j];
|
||||
if (!docJ) continue;
|
||||
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}`,
|
||||
|
|
@ -298,11 +311,11 @@ export function useGraphData(
|
|||
visualProps: getConnectionVisualProps(sim),
|
||||
color: getMagicalConnectionColor(sim, 200),
|
||||
edgeType: "doc-doc",
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: allNodes, edges: allEdges };
|
||||
}, [data, selectedSpace, nodePositions, draggingNodeId]);
|
||||
return { nodes: allNodes, edges: allEdges }
|
||||
}, [data, selectedSpace, nodePositions, draggingNodeId, memoryLimit])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { GRAPH_SETTINGS } from "@/constants";
|
||||
import type { GraphNode } from "@/types";
|
||||
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 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 [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());
|
||||
>(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: { 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);
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
// Smooth animation helper
|
||||
const animateToViewState = useCallback(
|
||||
|
|
@ -53,219 +53,219 @@ export function useGraphInteractions(
|
|||
duration = 300,
|
||||
) => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
|
||||
const startPanX = panX;
|
||||
const startPanY = panY;
|
||||
const startZoom = zoom;
|
||||
const startTime = Date.now();
|
||||
const startPanX = panX
|
||||
const startPanY = panY
|
||||
const startZoom = zoom
|
||||
const startTime = Date.now()
|
||||
|
||||
setIsAnimating(true);
|
||||
setIsAnimating(true)
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
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 easeOut = 1 - (1 - progress) ** 3
|
||||
|
||||
const currentPanX = startPanX + (targetPanX - startPanX) * easeOut;
|
||||
const currentPanY = startPanY + (targetPanY - startPanY) * easeOut;
|
||||
const currentZoom = startZoom + (targetZoom - startZoom) * easeOut;
|
||||
const currentPanX = startPanX + (targetPanX - startPanX) * easeOut
|
||||
const currentPanY = startPanY + (targetPanY - startPanY) * easeOut
|
||||
const currentZoom = startZoom + (targetZoom - startZoom) * easeOut
|
||||
|
||||
setPanX(currentPanX);
|
||||
setPanY(currentPanY);
|
||||
setZoom(currentZoom);
|
||||
setPanX(currentPanX)
|
||||
setPanY(currentPanY)
|
||||
setZoom(currentZoom)
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
animationRef.current = null;
|
||||
setIsAnimating(false)
|
||||
animationRef.current = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
animate();
|
||||
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;
|
||||
const node = nodes?.find((n) => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
setDraggingNodeId(nodeId);
|
||||
setDraggingNodeId(nodeId)
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
nodeX: node.x,
|
||||
nodeY: node.y,
|
||||
});
|
||||
})
|
||||
},
|
||||
[],
|
||||
);
|
||||
)
|
||||
|
||||
const handleNodeDragMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!draggingNodeId) return;
|
||||
if (!draggingNodeId) return
|
||||
|
||||
const deltaX = (e.clientX - dragStart.x) / zoom;
|
||||
const deltaY = (e.clientY - dragStart.y) / zoom;
|
||||
const deltaX = (e.clientX - dragStart.x) / zoom
|
||||
const deltaY = (e.clientY - dragStart.y) / zoom
|
||||
|
||||
const newX = dragStart.nodeX + deltaX;
|
||||
const newY = dragStart.nodeY + deltaY;
|
||||
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);
|
||||
}, []);
|
||||
setDraggingNodeId(null)
|
||||
}, [])
|
||||
|
||||
// Pan handlers
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX - panX, y: e.clientY - panY });
|
||||
setIsPanning(true)
|
||||
setPanStart({ x: e.clientX - panX, y: e.clientY - panY })
|
||||
},
|
||||
[panX, panY],
|
||||
);
|
||||
)
|
||||
|
||||
const handlePanMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanning || draggingNodeId) return;
|
||||
if (!isPanning || draggingNodeId) return
|
||||
|
||||
const newPanX = e.clientX - panStart.x;
|
||||
const newPanY = e.clientY - panStart.y;
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
const newPanX = e.clientX - panStart.x
|
||||
const newPanY = e.clientY - panStart.y
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
},
|
||||
[isPanning, panStart, draggingNodeId],
|
||||
);
|
||||
)
|
||||
|
||||
const handlePanEnd = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
setIsPanning(false)
|
||||
}, [])
|
||||
|
||||
// Zoom handlers
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
// Always prevent default to stop browser navigation
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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;
|
||||
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));
|
||||
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;
|
||||
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;
|
||||
const target = e.currentTarget
|
||||
if (target && "getBoundingClientRect" in target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
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;
|
||||
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;
|
||||
const newPanX = mouseX - worldX * newZoom
|
||||
const newPanY = mouseY - worldY * newZoom
|
||||
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
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
|
||||
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;
|
||||
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);
|
||||
animateToViewState(newPanX, newPanY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
} else {
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(panX, panY, newZoom, 200);
|
||||
animateToViewState(panX, panY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
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
|
||||
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;
|
||||
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);
|
||||
animateToViewState(newPanX, newPanY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
} else {
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(panX, panY, newZoom, 200);
|
||||
animateToViewState(panX, panY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
setZoom(newZoom)
|
||||
}
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY, isAnimating, animateToViewState],
|
||||
);
|
||||
)
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setPanX(settings.initialPanX);
|
||||
setPanY(settings.initialPanY);
|
||||
setZoom(settings.initialZoom);
|
||||
setNodePositions(new Map());
|
||||
}, [settings]);
|
||||
setPanX(settings.initialPanX)
|
||||
setPanY(settings.initialPanY)
|
||||
setZoom(settings.initialZoom)
|
||||
setNodePositions(new Map())
|
||||
}, [settings])
|
||||
|
||||
// Auto-fit graph to viewport
|
||||
const autoFitToViewport = useCallback(
|
||||
|
|
@ -275,74 +275,74 @@ export function useGraphInteractions(
|
|||
viewportHeight: number,
|
||||
options?: { occludedRightPx?: number; animate?: boolean },
|
||||
) => {
|
||||
if (nodes.length === 0) return;
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
const contentCenterX = (minX + maxX) / 2
|
||||
const contentCenterY = (minY + maxY) / 2
|
||||
|
||||
// Calculate the size of the content
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
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 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);
|
||||
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);
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
);
|
||||
)
|
||||
|
||||
// Touch gesture handlers for mobile pinch-to-zoom
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
|
|
@ -350,117 +350,117 @@ export function useGraphInteractions(
|
|||
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 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 }));
|
||||
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }))
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
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 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));
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
const centerDx = center.x - touchState.lastCenter.x
|
||||
const centerDy = center.y - touchState.lastCenter.y
|
||||
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX + centerDx);
|
||||
setPanY(newPanY + centerDy);
|
||||
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);
|
||||
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 }));
|
||||
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }))
|
||||
} else {
|
||||
setTouchState((prev) => ({ ...prev, touches }));
|
||||
setTouchState((prev) => ({ ...prev, touches }))
|
||||
}
|
||||
|
||||
if (touches.length === 0) {
|
||||
setIsPanning(false);
|
||||
setIsPanning(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// Center viewport on a specific world position (with animation)
|
||||
const centerViewportOn = useCallback(
|
||||
|
|
@ -471,63 +471,63 @@ export function useGraphInteractions(
|
|||
viewportHeight: number,
|
||||
animate = true,
|
||||
) => {
|
||||
const newPanX = viewportWidth / 2 - worldX * zoom;
|
||||
const newPanY = viewportHeight / 2 - worldY * zoom;
|
||||
const newPanX = viewportWidth / 2 - worldX * zoom
|
||||
const newPanY = viewportHeight / 2 - worldY * zoom
|
||||
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(newPanX, newPanY, zoom, 400);
|
||||
animateToViewState(newPanX, newPanY, zoom, 400)
|
||||
} else {
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
},
|
||||
[zoom, isAnimating, animateToViewState],
|
||||
);
|
||||
)
|
||||
|
||||
// Node interaction handlers
|
||||
const handleNodeHover = useCallback((nodeId: string | null) => {
|
||||
setHoveredNode(nodeId);
|
||||
}, []);
|
||||
setHoveredNode(nodeId)
|
||||
}, [])
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
setSelectedNode(selectedNode === nodeId ? null : nodeId);
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
const target = e.currentTarget
|
||||
if (target && "getBoundingClientRect" in target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
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;
|
||||
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;
|
||||
const newPanX = mouseX - worldX * newZoom
|
||||
const newPanY = mouseY - worldY * newZoom
|
||||
|
||||
setZoom(newZoom);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
);
|
||||
)
|
||||
|
||||
return {
|
||||
// State
|
||||
|
|
@ -560,5 +560,5 @@ export function useGraphInteractions(
|
|||
autoFitToViewport,
|
||||
centerViewportOn,
|
||||
setSelectedNode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
// Export the main component
|
||||
export { MemoryGraph } from "./components/memory-graph";
|
||||
export { MemoryGraph } from "./components/memory-graph"
|
||||
|
||||
// Export style injector for manual use if needed
|
||||
export { injectStyles } from "./lib/inject-styles";
|
||||
export { injectStyles } from "./lib/inject-styles"
|
||||
|
||||
// Export types for consumers
|
||||
export type { MemoryGraphProps } from "./types";
|
||||
export type { MemoryGraphProps } from "./types"
|
||||
|
||||
export type {
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
DocumentsResponse,
|
||||
} from "./api-types";
|
||||
} from "./api-types"
|
||||
|
||||
export type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
MemoryRelation,
|
||||
} from "./types";
|
||||
} from "./types"
|
||||
|
||||
// Export theme system for custom theming
|
||||
export { themeContract, defaultTheme } from "./styles/theme.css";
|
||||
export { sprinkles } from "./styles/sprinkles.css";
|
||||
export type { Sprinkles } from "./styles/sprinkles.css";
|
||||
export { themeContract, defaultTheme } from "./styles/theme.css"
|
||||
export { sprinkles } from "./styles/sprinkles.css"
|
||||
export type { Sprinkles } from "./styles/sprinkles.css"
|
||||
|
|
|
|||
|
|
@ -4,33 +4,33 @@
|
|||
*/
|
||||
|
||||
// This will be replaced by the build plugin with the actual CSS content
|
||||
declare const __MEMORY_GRAPH_CSS__: string;
|
||||
declare const __MEMORY_GRAPH_CSS__: string
|
||||
|
||||
// Track injection state
|
||||
let injected = false;
|
||||
let injected = false
|
||||
|
||||
/**
|
||||
* Inject memory-graph styles into the document head.
|
||||
* Safe to call multiple times - will only inject once.
|
||||
*/
|
||||
export function injectStyles(): void {
|
||||
// Only run in browser
|
||||
if (typeof document === "undefined") return;
|
||||
// Only run in browser
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
// Only inject once
|
||||
if (injected) return;
|
||||
// Only inject once
|
||||
if (injected) return
|
||||
|
||||
// Check if already injected (e.g., by another instance)
|
||||
if (document.querySelector('style[data-memory-graph]')) {
|
||||
injected = true;
|
||||
return;
|
||||
}
|
||||
// Check if already injected (e.g., by another instance)
|
||||
if (document.querySelector("style[data-memory-graph]")) {
|
||||
injected = true
|
||||
return
|
||||
}
|
||||
|
||||
injected = true;
|
||||
injected = true
|
||||
|
||||
// Create and inject style element
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-memory-graph", "");
|
||||
style.textContent = __MEMORY_GRAPH_CSS__;
|
||||
document.head.appendChild(style);
|
||||
// Create and inject style element
|
||||
const style = document.createElement("style")
|
||||
style.setAttribute("data-memory-graph", "")
|
||||
style.textContent = __MEMORY_GRAPH_CSS__
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { keyframes } from "@vanilla-extract/css";
|
||||
import { keyframes } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Animation keyframes
|
||||
|
|
@ -8,12 +8,12 @@ import { keyframes } from "@vanilla-extract/css";
|
|||
export const fadeIn = keyframes({
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
});
|
||||
})
|
||||
|
||||
export const fadeOut = keyframes({
|
||||
from: { opacity: 1 },
|
||||
to: { opacity: 0 },
|
||||
});
|
||||
})
|
||||
|
||||
export const slideInFromRight = keyframes({
|
||||
from: {
|
||||
|
|
@ -24,7 +24,7 @@ export const slideInFromRight = keyframes({
|
|||
transform: "translateX(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const slideInFromLeft = keyframes({
|
||||
from: {
|
||||
|
|
@ -35,7 +35,7 @@ export const slideInFromLeft = keyframes({
|
|||
transform: "translateX(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const slideInFromTop = keyframes({
|
||||
from: {
|
||||
|
|
@ -46,7 +46,7 @@ export const slideInFromTop = keyframes({
|
|||
transform: "translateY(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const slideInFromBottom = keyframes({
|
||||
from: {
|
||||
|
|
@ -57,12 +57,12 @@ export const slideInFromBottom = keyframes({
|
|||
transform: "translateY(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const spin = keyframes({
|
||||
from: { transform: "rotate(0deg)" },
|
||||
to: { transform: "rotate(360deg)" },
|
||||
});
|
||||
})
|
||||
|
||||
export const pulse = keyframes({
|
||||
"0%, 100%": {
|
||||
|
|
@ -71,7 +71,7 @@ export const pulse = keyframes({
|
|||
"50%": {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const bounce = keyframes({
|
||||
"0%, 100%": {
|
||||
|
|
@ -82,7 +82,7 @@ export const bounce = keyframes({
|
|||
transform: "translateY(0)",
|
||||
animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const scaleIn = keyframes({
|
||||
from: {
|
||||
|
|
@ -93,7 +93,7 @@ export const scaleIn = keyframes({
|
|||
transform: "scale(1)",
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const scaleOut = keyframes({
|
||||
from: {
|
||||
|
|
@ -104,7 +104,7 @@ export const scaleOut = keyframes({
|
|||
transform: "scale(0.95)",
|
||||
opacity: 0,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const shimmer = keyframes({
|
||||
"0%": {
|
||||
|
|
@ -113,4 +113,4 @@ export const shimmer = keyframes({
|
|||
"100%": {
|
||||
backgroundPosition: "1000px 0",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { style, styleVariants } from "@vanilla-extract/css";
|
||||
import { themeContract } from "./theme.css";
|
||||
import { style, styleVariants } from "@vanilla-extract/css"
|
||||
import { themeContract } from "./theme.css"
|
||||
|
||||
/**
|
||||
* Base glass-morphism effect
|
||||
|
|
@ -10,7 +10,7 @@ const glassBase = style({
|
|||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: `1px solid ${themeContract.colors.document.border}`,
|
||||
borderRadius: themeContract.radii.lg,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Glass effect variants
|
||||
|
|
@ -47,7 +47,7 @@ export const glass = styleVariants({
|
|||
WebkitBackdropFilter: "blur(20px)",
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Glass panel styles for larger containers
|
||||
|
|
@ -67,7 +67,7 @@ export const glassPanel = styleVariants({
|
|||
border: `2px solid ${themeContract.colors.document.border}`,
|
||||
borderRadius: themeContract.radii.xl,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Focus ring styles for accessibility
|
||||
|
|
@ -80,7 +80,7 @@ export const focusRing = style({
|
|||
outlineOffset: "2px",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Transition presets
|
||||
|
|
@ -104,7 +104,7 @@ export const transition = styleVariants({
|
|||
transform: {
|
||||
transition: `transform ${themeContract.transitions.normal}`,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Hover glow effect
|
||||
|
|
@ -117,4 +117,4 @@ export const hoverGlow = style({
|
|||
boxShadow: `0 0 20px ${themeContract.colors.document.glow}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { globalStyle } from "@vanilla-extract/css";
|
||||
import { globalStyle } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Global CSS reset and base styles
|
||||
|
|
@ -7,39 +7,39 @@ import { globalStyle } from "@vanilla-extract/css";
|
|||
// Box sizing reset
|
||||
globalStyle("*, *::before, *::after", {
|
||||
boxSizing: "border-box",
|
||||
});
|
||||
})
|
||||
|
||||
// Remove default margins
|
||||
globalStyle("body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd", {
|
||||
margin: 0,
|
||||
});
|
||||
})
|
||||
|
||||
// Remove list styles
|
||||
globalStyle("ul[role='list'], ol[role='list']", {
|
||||
listStyle: "none",
|
||||
});
|
||||
})
|
||||
|
||||
// Core body defaults
|
||||
globalStyle("html, body", {
|
||||
height: "100%",
|
||||
});
|
||||
})
|
||||
|
||||
globalStyle("body", {
|
||||
lineHeight: 1.5,
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
MozOsxFontSmoothing: "grayscale",
|
||||
});
|
||||
})
|
||||
|
||||
// Typography defaults
|
||||
globalStyle("h1, h2, h3, h4, h5, h6", {
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.25,
|
||||
});
|
||||
})
|
||||
|
||||
// Inherit fonts for inputs and buttons
|
||||
globalStyle("input, button, textarea, select", {
|
||||
font: "inherit",
|
||||
});
|
||||
})
|
||||
|
||||
// Remove default button styles
|
||||
globalStyle("button", {
|
||||
|
|
@ -47,25 +47,25 @@ globalStyle("button", {
|
|||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
});
|
||||
})
|
||||
|
||||
// Improve media defaults
|
||||
globalStyle("img, picture, video, canvas, svg", {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
});
|
||||
})
|
||||
|
||||
// Remove built-in form typography styles
|
||||
globalStyle("input, button, textarea, select", {
|
||||
font: "inherit",
|
||||
});
|
||||
})
|
||||
|
||||
// Avoid text overflows
|
||||
globalStyle("p, h1, h2, h3, h4, h5, h6", {
|
||||
overflowWrap: "break-word",
|
||||
});
|
||||
})
|
||||
|
||||
// Improve text rendering
|
||||
globalStyle("#root, #__next", {
|
||||
isolation: "isolate",
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,17 +4,23 @@
|
|||
*/
|
||||
|
||||
// Import global styles (side effect)
|
||||
import "./global.css";
|
||||
import "./global.css"
|
||||
|
||||
// Theme
|
||||
export { themeContract, defaultTheme } from "./theme.css";
|
||||
export { themeContract, defaultTheme } from "./theme.css"
|
||||
|
||||
// Sprinkles utilities
|
||||
export { sprinkles } from "./sprinkles.css";
|
||||
export type { Sprinkles } from "./sprinkles.css";
|
||||
export { sprinkles } from "./sprinkles.css"
|
||||
export type { Sprinkles } from "./sprinkles.css"
|
||||
|
||||
// Animations
|
||||
export * as animations from "./animations.css";
|
||||
export * as animations from "./animations.css"
|
||||
|
||||
// Glass-morphism effects
|
||||
export { glass, glassPanel, focusRing, transition, hoverGlow } from "./effects.css";
|
||||
export {
|
||||
glass,
|
||||
glassPanel,
|
||||
focusRing,
|
||||
transition,
|
||||
hoverGlow,
|
||||
} from "./effects.css"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles";
|
||||
import { themeContract } from "./theme.css";
|
||||
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"
|
||||
import { themeContract } from "./theme.css"
|
||||
|
||||
/**
|
||||
* Responsive conditions for mobile-first design
|
||||
|
|
@ -122,7 +122,7 @@ const responsiveProperties = defineProperties({
|
|||
// User select
|
||||
userSelect: ["auto", "none", "text", "all"],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Color properties (non-responsive)
|
||||
|
|
@ -152,7 +152,7 @@ const colorProperties = defineProperties({
|
|||
memoryBorder: themeContract.colors.memory.border,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Border properties
|
||||
|
|
@ -167,7 +167,7 @@ const borderProperties = defineProperties({
|
|||
},
|
||||
borderStyle: ["none", "solid", "dashed", "dotted"],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Opacity properties
|
||||
|
|
@ -188,7 +188,7 @@ const opacityProperties = defineProperties({
|
|||
100: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Combined sprinkles system
|
||||
|
|
@ -199,6 +199,6 @@ export const sprinkles = createSprinkles(
|
|||
colorProperties,
|
||||
borderProperties,
|
||||
opacityProperties,
|
||||
);
|
||||
)
|
||||
|
||||
export type Sprinkles = Parameters<typeof sprinkles>[0];
|
||||
export type Sprinkles = Parameters<typeof sprinkles>[0]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createTheme, createThemeContract } from "@vanilla-extract/css";
|
||||
import { createTheme, createThemeContract } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Theme contract defines the structure of the design system.
|
||||
|
|
@ -124,7 +124,7 @@ export const themeContract = createThemeContract({
|
|||
modal: null,
|
||||
tooltip: null,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Default theme implementation based on the original constants.ts colors
|
||||
|
|
@ -242,4 +242,4 @@ export const defaultTheme = createTheme(themeContract, {
|
|||
modal: "30",
|
||||
tooltip: "40",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,130 +1,148 @@
|
|||
import type { DocumentsResponse, DocumentWithMemories, MemoryEntry } from "./api-types";
|
||||
import type {
|
||||
DocumentsResponse,
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "./api-types"
|
||||
|
||||
// Re-export for convenience
|
||||
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry };
|
||||
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
type: "document" | "memory";
|
||||
x: number;
|
||||
y: number;
|
||||
data: DocumentWithMemories | MemoryEntry;
|
||||
size: number;
|
||||
color: string;
|
||||
isHovered: boolean;
|
||||
isDragging: boolean;
|
||||
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 type MemoryRelation = "updates" | "extends" | "derives"
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
similarity: number;
|
||||
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;
|
||||
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;
|
||||
selectedSpace: string
|
||||
availableSpaces: string[]
|
||||
spaceMemoryCounts: Record<string, number>
|
||||
onSpaceChange: (space: string) => void
|
||||
}
|
||||
|
||||
export interface NodeDetailPanelProps {
|
||||
node: GraphNode | null;
|
||||
onClose: () => void;
|
||||
variant?: "console" | "consumer";
|
||||
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;
|
||||
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[];
|
||||
highlightDocumentIds?: string[]
|
||||
}
|
||||
|
||||
export interface MemoryGraphProps {
|
||||
/** The documents to display in the graph */
|
||||
documents: DocumentWithMemories[];
|
||||
documents: DocumentWithMemories[]
|
||||
/** Whether the initial data is loading */
|
||||
isLoading?: boolean;
|
||||
isLoading?: boolean
|
||||
/** Error that occurred during data fetching */
|
||||
error?: Error | null;
|
||||
error?: Error | null
|
||||
/** Optional children to render when no documents exist */
|
||||
children?: React.ReactNode;
|
||||
children?: React.ReactNode
|
||||
/** Whether more data is being loaded (for pagination) */
|
||||
isLoadingMore?: boolean;
|
||||
isLoadingMore?: boolean
|
||||
/** Total number of documents loaded */
|
||||
totalLoaded?: number;
|
||||
totalLoaded?: number
|
||||
/** Whether there are more documents to load */
|
||||
hasMore?: boolean;
|
||||
hasMore?: boolean
|
||||
/** Callback to load more documents (for pagination) */
|
||||
loadMoreDocuments?: () => Promise<void>;
|
||||
loadMoreDocuments?: () => Promise<void>
|
||||
/** Show/hide the spaces filter dropdown */
|
||||
showSpacesSelector?: boolean;
|
||||
showSpacesSelector?: boolean
|
||||
/** Visual variant - "console" for full view, "consumer" for embedded */
|
||||
variant?: "console" | "consumer";
|
||||
variant?: "console" | "consumer"
|
||||
/** Optional ID for the legend component */
|
||||
legendId?: string;
|
||||
legendId?: string
|
||||
/** Document IDs to highlight in the graph */
|
||||
highlightDocumentIds?: string[];
|
||||
highlightDocumentIds?: string[]
|
||||
/** Whether highlights are currently visible */
|
||||
highlightsVisible?: boolean;
|
||||
highlightsVisible?: boolean
|
||||
/** Pixels occluded on the right side of the viewport */
|
||||
occludedRightPx?: number;
|
||||
occludedRightPx?: number
|
||||
/** Whether to auto-load more documents based on viewport visibility */
|
||||
autoLoadOnViewport?: boolean;
|
||||
autoLoadOnViewport?: boolean
|
||||
/** Theme class name to apply */
|
||||
themeClassName?: string;
|
||||
themeClassName?: string
|
||||
|
||||
// External space control
|
||||
/** Currently selected space (for controlled component) */
|
||||
selectedSpace?: string
|
||||
/** Callback when space selection changes (for controlled component) */
|
||||
onSpaceChange?: (spaceId: string) => void
|
||||
|
||||
// Memory limit control
|
||||
/** Maximum number of memories to display per document when a space is selected */
|
||||
memoryLimit?: number
|
||||
|
||||
// Feature flags
|
||||
/** Enable experimental features */
|
||||
isExperimental?: boolean
|
||||
}
|
||||
|
||||
export interface LegendProps {
|
||||
variant?: "console" | "consumer";
|
||||
nodes?: GraphNode[];
|
||||
edges?: GraphEdge[];
|
||||
isLoading?: boolean;
|
||||
hoveredNode?: string | null;
|
||||
variant?: "console" | "consumer"
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
hoveredNode?: string | null
|
||||
}
|
||||
|
||||
export interface LoadingIndicatorProps {
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
totalLoaded: number;
|
||||
variant?: "console" | "consumer";
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
totalLoaded: number
|
||||
variant?: "console" | "consumer"
|
||||
}
|
||||
|
||||
export interface ControlsProps {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
variant?: "console" | "consumer";
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onResetView: () => void
|
||||
variant?: "console" | "consumer"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes";
|
||||
import { style, globalStyle } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"
|
||||
import { style, globalStyle } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Base styles for SVG icons inside badges
|
||||
|
|
@ -9,7 +9,7 @@ export const badgeIcon = style({
|
|||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Badge recipe with variants
|
||||
|
|
@ -44,14 +44,14 @@ const badgeBase = style({
|
|||
borderColor: themeContract.colors.status.forgotten,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Global style for SVG children
|
||||
globalStyle(`${badgeBase} > svg`, {
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
})
|
||||
|
||||
export const badge = recipe({
|
||||
base: badgeBase,
|
||||
|
|
@ -114,6 +114,6 @@ export const badge = recipe({
|
|||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export type BadgeVariants = RecipeVariants<typeof badge>;
|
||||
export type BadgeVariants = RecipeVariants<typeof badge>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,20 @@
|
|||
import { Slot } from "@radix-ui/react-slot";
|
||||
import type * as React from "react";
|
||||
import { badge, type BadgeVariants } from "./badge.css";
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import type * as React from "react"
|
||||
import { badge, type BadgeVariants } from "./badge.css"
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
BadgeVariants & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
}: React.ComponentProps<"span"> & BadgeVariants & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
const combinedClassName = className
|
||||
? `${badge({ variant })} ${className}`
|
||||
: badge({ variant });
|
||||
: badge({ variant })
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={combinedClassName}
|
||||
data-slot="badge"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <Comp className={combinedClassName} data-slot="badge" {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badge as badgeVariants };
|
||||
export { Badge, badge as badgeVariants }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Base styles for SVG icons inside buttons
|
||||
|
|
@ -14,7 +14,7 @@ export const buttonIcon = style({
|
|||
height: "1rem",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Button recipe with variants
|
||||
|
|
@ -205,6 +205,6 @@ export const button = recipe({
|
|||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export type ButtonVariants = RecipeVariants<typeof button>;
|
||||
export type ButtonVariants = RecipeVariants<typeof button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Slot } from "@radix-ui/react-slot";
|
||||
import type * as React from "react";
|
||||
import { button, type ButtonVariants } from "./button.css";
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import type * as React from "react"
|
||||
import { button, type ButtonVariants } from "./button.css"
|
||||
|
||||
function Button({
|
||||
className,
|
||||
|
|
@ -10,21 +10,15 @@ function Button({
|
|||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
ButtonVariants & {
|
||||
asChild?: boolean;
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
const combinedClassName = className
|
||||
? `${button({ variant, size })} ${className}`
|
||||
: button({ variant, size });
|
||||
: button({ variant, size })
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={combinedClassName}
|
||||
data-slot="button"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <Comp className={combinedClassName} data-slot="button" {...props} />
|
||||
}
|
||||
|
||||
export { Button, button as buttonVariants };
|
||||
export { Button, button as buttonVariants }
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
|
|
@ -16,7 +16,7 @@ function CollapsibleTrigger({
|
|||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
|
|
@ -27,7 +27,7 @@ function CollapsibleContent({
|
|||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { recipe } from "@vanilla-extract/recipes"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Glass menu effect container
|
||||
|
|
@ -8,7 +8,7 @@ import { themeContract } from "../styles/theme.css";
|
|||
export const glassMenuContainer = style({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Glass menu effect with customizable border radius
|
||||
|
|
@ -55,4 +55,4 @@ export const glassMenuEffect = recipe({
|
|||
defaultVariants: {
|
||||
rounded: "3xl",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import {
|
||||
glassMenuContainer,
|
||||
glassMenuEffect,
|
||||
} from "./glass-effect.css";
|
||||
import { glassMenuContainer, glassMenuEffect } from "./glass-effect.css"
|
||||
|
||||
interface GlassMenuEffectProps {
|
||||
rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full";
|
||||
className?: string;
|
||||
rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlassMenuEffect({
|
||||
|
|
@ -17,5 +14,5 @@ export function GlassMenuEffect({
|
|||
{/* Frosted glass effect with translucent border */}
|
||||
<div className={glassMenuEffect({ rounded })} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { themeContract } from "../styles/theme.css";
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Responsive heading style with bold weight
|
||||
|
|
@ -21,4 +21,4 @@ export const headingH3Bold = style({
|
|||
fontSize: themeContract.typography.fontSize.base, // 16px
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { Root } from "@radix-ui/react-slot";
|
||||
import { headingH3Bold } from "./heading.css";
|
||||
import { Root } from "@radix-ui/react-slot"
|
||||
import { headingH3Bold } from "./heading.css"
|
||||
|
||||
export function HeadingH3Bold({
|
||||
className,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<"h3"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Root : "h3";
|
||||
const Comp = asChild ? Root : "h3"
|
||||
|
||||
const combinedClassName = className
|
||||
? `${headingH3Bold} ${className}`
|
||||
: headingH3Bold;
|
||||
: headingH3Bold
|
||||
|
||||
return (
|
||||
<Comp className={combinedClassName} {...props} />
|
||||
);
|
||||
return <Comp className={combinedClassName} {...props} />
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue