aboutsummaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorMahesh Sanikommmu <[email protected]>2025-08-16 18:50:10 -0700
committerMahesh Sanikommmu <[email protected]>2025-08-16 18:50:10 -0700
commit39003aff23d64ff1d96074d71521f6023c9bec01 (patch)
tree3f870c04b3dce315bba1b21aa2da158494e71774 /packages
parentMerge pull request #355 from supermemoryai/archive (diff)
downloadsupermemory-39003aff23d64ff1d96074d71521f6023c9bec01.tar.xz
supermemory-39003aff23d64ff1d96074d71521f6023c9bec01.zip
New Version of Supermemory Consumer App
Diffstat (limited to 'packages')
-rw-r--r--packages/db/index.ts46
-rw-r--r--packages/db/package.json8
-rw-r--r--packages/db/schema.ts264
-rw-r--r--packages/eslint-config/README.md3
-rw-r--r--packages/eslint-config/base.js32
-rw-r--r--packages/eslint-config/next.js49
-rw-r--r--packages/eslint-config/package.json24
-rw-r--r--packages/eslint-config/react-internal.js39
-rw-r--r--packages/hooks/package.json5
-rw-r--r--packages/hooks/tsconfig.json11
-rw-r--r--packages/hooks/use-keypress.ts15
-rw-r--r--packages/hooks/use-mobile.ts19
-rw-r--r--packages/lib/api.ts208
-rw-r--r--packages/lib/auth-context.tsx65
-rw-r--r--packages/lib/auth.middleware.ts26
-rw-r--r--packages/lib/auth.ts32
-rw-r--r--packages/lib/constants.ts4
-rw-r--r--packages/lib/error-tracking.tsx191
-rw-r--r--packages/lib/generate-id.ts6
-rw-r--r--packages/lib/glass-effect-manager.ts308
-rw-r--r--packages/lib/package.json12
-rw-r--r--packages/lib/posthog.tsx84
-rw-r--r--packages/lib/queries.ts86
-rw-r--r--packages/lib/query-client.tsx27
-rw-r--r--packages/lib/similarity.ts115
-rw-r--r--packages/lib/tsconfig.json11
-rw-r--r--packages/lib/utils.ts8
-rw-r--r--packages/shared/icons.tsx64
-rw-r--r--packages/shared/package.json12
-rw-r--r--packages/shared/tsconfig.json15
-rw-r--r--packages/shared/types.ts22
-rw-r--r--packages/shared/utils.ts164
-rw-r--r--packages/typescript-config/base.json19
-rw-r--r--packages/typescript-config/nextjs.json12
-rw-r--r--packages/typescript-config/package.json9
-rw-r--r--packages/typescript-config/react-library.json7
-rw-r--r--packages/ui/assets/Logo.tsx51
-rw-r--r--packages/ui/assets/icons.tsx208
-rw-r--r--packages/ui/biome.json10
-rw-r--r--packages/ui/button/external-auth.tsx29
-rw-r--r--packages/ui/components.json20
-rw-r--r--packages/ui/components/accordion.tsx65
-rw-r--r--packages/ui/components/alert-dialog.tsx156
-rw-r--r--packages/ui/components/avatar.tsx52
-rw-r--r--packages/ui/components/badge.tsx45
-rw-r--r--packages/ui/components/breadcrumb.tsx109
-rw-r--r--packages/ui/components/button.tsx58
-rw-r--r--packages/ui/components/card.tsx91
-rw-r--r--packages/ui/components/carousel.tsx238
-rw-r--r--packages/ui/components/chart.tsx353
-rw-r--r--packages/ui/components/checkbox.tsx31
-rw-r--r--packages/ui/components/collapsible.tsx33
-rw-r--r--packages/ui/components/combobox.tsx161
-rw-r--r--packages/ui/components/command.tsx183
-rw-r--r--packages/ui/components/dialog.tsx142
-rw-r--r--packages/ui/components/drawer.tsx134
-rw-r--r--packages/ui/components/dropdown-menu.tsx256
-rw-r--r--packages/ui/components/input.tsx20
-rw-r--r--packages/ui/components/label.tsx23
-rw-r--r--packages/ui/components/popover.tsx47
-rw-r--r--packages/ui/components/progress.tsx30
-rw-r--r--packages/ui/components/scroll-area.tsx57
-rw-r--r--packages/ui/components/select.tsx184
-rw-r--r--packages/ui/components/separator.tsx27
-rw-r--r--packages/ui/components/shadcn-io/dropzone.tsx202
-rw-r--r--packages/ui/components/sheet.tsx138
-rw-r--r--packages/ui/components/sidebar.tsx725
-rw-r--r--packages/ui/components/skeleton.tsx13
-rw-r--r--packages/ui/components/sonner.tsx25
-rw-r--r--packages/ui/components/table.tsx115
-rw-r--r--packages/ui/components/tabs.tsx65
-rw-r--r--packages/ui/components/text-separator.tsx24
-rw-r--r--packages/ui/components/textarea.tsx17
-rw-r--r--packages/ui/components/toggle-group.tsx72
-rw-r--r--packages/ui/components/toggle.tsx46
-rw-r--r--packages/ui/components/tooltip.tsx60
-rw-r--r--packages/ui/copy-button.tsx50
-rw-r--r--packages/ui/copyable-cell.tsx84
-rw-r--r--packages/ui/globals.css225
-rw-r--r--packages/ui/input/labeled-input.tsx42
-rw-r--r--packages/ui/memory-graph/constants.ts100
-rw-r--r--packages/ui/memory-graph/controls.tsx67
-rw-r--r--packages/ui/memory-graph/graph-canvas.tsx736
-rw-r--r--packages/ui/memory-graph/graph-webgl-canvas.tsx781
-rw-r--r--packages/ui/memory-graph/hooks/use-graph-data.ts299
-rw-r--r--packages/ui/memory-graph/hooks/use-graph-interactions.ts257
-rw-r--r--packages/ui/memory-graph/index.ts19
-rw-r--r--packages/ui/memory-graph/legend.tsx323
-rw-r--r--packages/ui/memory-graph/loading-indicator.tsx44
-rw-r--r--packages/ui/memory-graph/memory-graph.tsx368
-rw-r--r--packages/ui/memory-graph/node-detail-panel.tsx264
-rw-r--r--packages/ui/memory-graph/spaces-dropdown.tsx119
-rw-r--r--packages/ui/memory-graph/types.ts121
-rw-r--r--packages/ui/other/anonymous-auth.tsx150
-rw-r--r--packages/ui/other/glass-effect.tsx18
-rw-r--r--packages/ui/package.json36
-rw-r--r--packages/ui/pages/login.tsx354
-rw-r--r--packages/ui/text/heading/heading-h1-bold.tsx19
-rw-r--r--packages/ui/text/heading/heading-h1-medium.tsx19
-rw-r--r--packages/ui/text/heading/heading-h2-bold.tsx19
-rw-r--r--packages/ui/text/heading/heading-h2-medium.tsx19
-rw-r--r--packages/ui/text/heading/heading-h3-bold.tsx19
-rw-r--r--packages/ui/text/heading/heading-h3-medium.tsx19
-rw-r--r--packages/ui/text/heading/heading-h4-bold.tsx19
-rw-r--r--packages/ui/text/heading/heading-h4-medium.tsx19
-rw-r--r--packages/ui/text/label/label-1-medium.tsx19
-rw-r--r--packages/ui/text/label/label-1-regular.tsx19
-rw-r--r--packages/ui/text/label/label-2-medium.tsx19
-rw-r--r--packages/ui/text/label/label-2-regular.tsx19
-rw-r--r--packages/ui/text/label/label-3-medium.tsx19
-rw-r--r--packages/ui/text/label/label-3-regular.tsx19
-rw-r--r--packages/ui/text/title/title-1-bold.tsx19
-rw-r--r--packages/ui/text/title/title-1-medium.tsx19
-rw-r--r--packages/ui/text/title/title-2-bold.tsx19
-rw-r--r--packages/ui/text/title/title-2-medium.tsx19
-rw-r--r--packages/ui/text/title/title-3-bold.tsx19
-rw-r--r--packages/ui/text/title/title-3-medium.tsx19
-rw-r--r--packages/ui/tsconfig.json13
-rw-r--r--packages/validation/api.ts1378
-rw-r--r--packages/validation/connection.ts191
-rw-r--r--packages/validation/package.json6
-rw-r--r--packages/validation/schemas.ts380
-rw-r--r--packages/validation/tsconfig.json10
123 files changed, 12557 insertions, 595 deletions
diff --git a/packages/db/index.ts b/packages/db/index.ts
deleted file mode 100644
index b96559f9..00000000
--- a/packages/db/index.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Logger } from "drizzle-orm";
-import * as schema from "./schema";
-
-import { drizzle } from "drizzle-orm/postgres-js";
-import postgres from "postgres";
-
-class CustomLogger implements Logger {
- logQuery(query: string, params: unknown[]): void {
- // Truncate large chunks and embedding vectors for readability
- let truncatedQuery = query;
- let truncatedParams = [...params];
-
- // Truncate embedding vectors in query
- if (query.toLowerCase().includes("embedding")) {
- truncatedQuery = query.replace(/\[[\d\., -]+\]/g, "[...vector]");
- }
-
- // Truncate large array/object params
- truncatedParams = params.map((param) => {
- if (typeof param === "string" && param.match(/^\[[\d\., -]+\]$/)) {
- return "<query embeddings>";
- }
- if (Array.isArray(param) && param.length > 10) {
- return `[Array(${param.length})]`;
- }
- if (typeof param === "object" && param !== null) {
- const str = JSON.stringify(param);
- if (str.length > 100) {
- return `{Object(${str.length} chars)}`;
- }
- }
- return param;
- });
-
- console.log("Query:", truncatedQuery);
- console.log("Params:", truncatedParams);
- }
-}
-
-export const database = (databaseUrl: string) => {
- const pool = postgres(databaseUrl);
- return drizzle(pool, { schema, logger: new CustomLogger() });
-};
-
-export type Database = ReturnType<typeof drizzle<typeof schema>>;
-export * from "drizzle-orm";
diff --git a/packages/db/package.json b/packages/db/package.json
deleted file mode 100644
index 589e22e6..00000000
--- a/packages/db/package.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "name": "@supermemory/db",
- "version": "0.0.1",
- "exports": {
- ".": "./index.ts",
- "./schema": "./schema.ts"
- }
-}
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
deleted file mode 100644
index 216f25f7..00000000
--- a/packages/db/schema.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-import {
- vector,
- serial,
- bigserial,
- varchar,
- boolean,
- index,
- integer,
- pgTable,
- text,
- timestamp,
- uniqueIndex,
- jsonb,
-} from "drizzle-orm/pg-core";
-import { sql } from "drizzle-orm";
-import { Metadata } from "../../apps/backend/src/types";
-
-export const users = pgTable(
- "users",
- {
- id: serial("id").primaryKey(),
- uuid: varchar("uuid", { length: 36 }).notNull().unique(),
- email: text("email").notNull().unique(),
- firstName: text("first_name"),
- lastName: text("last_name"),
- emailVerified: boolean("email_verified").notNull().default(false),
- profilePictureUrl: text("profile_picture_url"),
- telegramId: varchar("telegram_id", { length: 255 }),
- hasOnboarded: integer("has_onboarded").notNull().default(0),
- createdAt: timestamp("created_at").notNull().defaultNow(),
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
-
- lastApiKeyGeneratedAt: timestamp("last_api_key_generated_at").defaultNow(),
- // totalMemories: integer("total_memories").notNull().default(0), // TODO: add this
- stripeCustomerId: text("stripe_customer_id"),
-
- tier: text("tier", { enum: ["free", "premium"] })
- .notNull()
- .default("free"),
- },
- (users) => ({
- usersIdIdx: uniqueIndex("users_id_idx").on(users.id),
- usersUuidIdx: uniqueIndex("users_uuid_idx").on(users.uuid),
- usersEmailIdx: uniqueIndex("users_email_idx").on(users.email),
- usersNameIdx: index("users_name_idx").on(users.firstName, users.lastName),
- usersCreatedAtIdx: index("users_created_at_idx").on(users.createdAt),
- usersTelegramIdIdx: uniqueIndex("users_telegram_id_idx").on(
- users.telegramId
- ),
- })
-);
-
-export const spaces = pgTable(
- "spaces",
- {
- id: bigserial("id", { mode: "number" }).notNull().primaryKey(),
- uuid: varchar("uuid", { length: 36 }).notNull().unique(),
- name: text("name").notNull(),
- createdAt: timestamp("created_at").notNull().defaultNow(),
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
- ownerId: integer("ownerId").notNull(),
- isPublic: boolean("is_public").notNull().default(false),
- },
- (spaces) => ({
- spacesIdIdx: uniqueIndex("spaces_id_idx").on(spaces.id),
- spacesOwnerIdIdx: index("spaces_owner_id_idx").on(spaces.ownerId),
- spacesNameIdx: index("spaces_name_idx").on(spaces.name),
- })
-);
-
-export const contentToSpace = pgTable(
- "content_to_space",
- {
- contentId: integer("content_id")
- .notNull()
- .references(() => documents.id, { onDelete: "cascade" }),
- spaceId: integer("space_id")
- .notNull()
- .references(() => spaces.id, { onDelete: "cascade" }),
- },
- (contentToSpace) => ({
- contentIdSpaceIdUnique: uniqueIndex("content_id_space_id_unique").on(
- contentToSpace.contentId,
- contentToSpace.spaceId
- ),
- })
-);
-
-export const spaceMembers = pgTable(
- "space_members",
- {
- spaceId: integer("spaceId")
- .notNull()
- .references(() => users.id, { onDelete: "restrict" }),
- userId: integer("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "restrict" }),
- },
- (spaceMembers) => ({
- spaceMembersSpaceUserIdx: uniqueIndex("space_members_space_user_idx").on(
- spaceMembers.spaceId,
- spaceMembers.userId
- ),
- })
-);
-
-export const savedSpaces = pgTable(
- "saved_spaces",
- {
- userId: integer("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- spaceId: integer("space_id")
- .notNull()
- .references(() => spaces.id, { onDelete: "cascade" }),
- savedAt: timestamp("saved_at").notNull().defaultNow(),
- },
- (savedSpaces) => ({
- savedSpacesUserSpaceIdx: uniqueIndex("saved_spaces_user_space_idx").on(
- savedSpaces.userId,
- savedSpaces.spaceId
- ),
- })
-);
-
-export const chatThreads = pgTable(
- "chat_threads",
- {
- id: bigserial("id", { mode: "number" }).notNull().primaryKey(),
- uuid: varchar("uuid", { length: 36 }).notNull().unique(),
- firstMessage: text("firstMessage").notNull(), // can also be re-written by ai depending on the conversation!
- userId: integer("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- messages: jsonb("messages").notNull(),
- createdAt: timestamp("created_at", { withTimezone: true })
- .notNull()
- .defaultNow(),
- },
- (thread) => ({
- chatThreadsUserIdx: index("chat_threads_user_idx").on(thread.userId),
- })
-);
-
-export const documentType = pgTable("document_type", {
- type: text("type").primaryKey(),
-});
-
-export const documents = pgTable(
- "documents",
- {
- id: bigserial("id", { mode: "number" }).notNull().primaryKey(),
- uuid: varchar("uuid", { length: 36 }).notNull().unique(),
- url: text("url"),
- createdAt: timestamp("created_at", { withTimezone: true })
- .notNull()
- .defaultNow(),
- updatedAt: timestamp("updated_at", { withTimezone: true }),
- type: text("type")
- .references(() => documentType.type)
- .notNull(),
- title: text("title"),
- description: text("description"),
- ogImage: text("og_image"),
- raw: text("raw"),
- userId: integer("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- content: text("content"),
- isSuccessfullyProcessed: boolean("is_successfully_processed").default(
- false
- ),
- errorMessage: text("error_message"),
- contentHash: text("content_hash"),
- metadata: jsonb("metadata"),
- },
- (table) => ({
- documentsIdIdx: uniqueIndex("document_id_idx").on(table.id),
- documentsUuidIdx: uniqueIndex("document_uuid_idx").on(table.uuid),
- documentsTypdIdx: index("document_type_idx").on(table.type),
- documentRawUserIdx: uniqueIndex("document_raw_user_idx").on(
- table.raw,
- table.userId
- ),
- searchIndex: index("documents_search_idx").using(
- "gin",
- sql`(
- setweight(to_tsvector('english', coalesce(${table.content}, '')),'A') ||
- setweight(to_tsvector('english', coalesce(${table.title}, '')),'B') ||
- setweight(to_tsvector('english', coalesce(${table.description}, '')),'C') ||
- setweight(to_tsvector('english', coalesce(${table.url}, '')),'D')
- )`
- ),
- })
-);
-
-export const spaceAccessStatus = pgTable("space_access_status", {
- status: text("status").primaryKey(),
-});
-
-export const spaceAccess = pgTable(
- "space_access",
- {
- spaceId: integer("space_id").references(() => spaces.id, {
- onDelete: "cascade",
- }),
- userEmail: varchar("user_email", { length: 512 }),
- status: text("status").references(() => spaceAccessStatus.status),
- accessType: text("access_type").notNull().default("read"), // 'read' or 'edit'
- },
- (spaceAccess) => ({
- spaceIdUserEmailIdx: uniqueIndex("space_id_user_email_idx").on(
- spaceAccess.spaceId,
- spaceAccess.userEmail
- ),
- })
-);
-
-export const chunk = pgTable(
- "chunks",
- {
- id: serial("id").primaryKey(),
- documentId: integer("document_id")
- .references(() => documents.id, { onDelete: "cascade" })
- .notNull(),
- textContent: text("text_content"),
- orderInDocument: integer("order_in_document").notNull(),
- embeddings: vector("embeddings", { dimensions: 768 }),
- metadata: jsonb("metadata").$type<Metadata>(),
- createdAt: timestamp("created_at", { withTimezone: true })
- .notNull()
- .defaultNow(),
- updated_at: timestamp("updated_at", { withTimezone: true })
- .notNull()
- .defaultNow(), // handle deletion on application layer
- },
- (chunk) => ({
- chunkIdIdx: uniqueIndex("chunk_id_idx").on(chunk.id),
- chunkDocumentIdIdx: index("chunk_document_id_idx").on(chunk.documentId),
- embeddingIndex: index("embeddingIndex").using(
- "hnsw",
- chunk.embeddings.op("vector_cosine_ops")
- ),
- })
-);
-
-
-export const waitlist = pgTable("waitlist", {
- email: varchar("email", { length: 512 }).primaryKey(),
- createdAt: timestamp("created_at", { withTimezone: true })
- .notNull()
- .defaultNow(),
-});
-
-export type User = typeof users.$inferSelect;
-export type Document = typeof documents.$inferSelect;
-export type Space = typeof spaces.$inferSelect;
-export type SpaceMember = typeof spaceMembers.$inferSelect;
-export type SavedSpace = typeof savedSpaces.$inferSelect;
-export type ChatThread = typeof chatThreads.$inferSelect;
-export type Chunk = typeof chunk.$inferSelect;
-export type ChunkInsert = typeof chunk.$inferInsert;
-export type DocumentType = typeof documentType.$inferSelect;
-export type ContentToSpace = typeof contentToSpace.$inferSelect;
diff --git a/packages/eslint-config/README.md b/packages/eslint-config/README.md
new file mode 100644
index 00000000..8b42d901
--- /dev/null
+++ b/packages/eslint-config/README.md
@@ -0,0 +1,3 @@
+# `@turbo/eslint-config`
+
+Collection of internal eslint configurations.
diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js
new file mode 100644
index 00000000..09d316ef
--- /dev/null
+++ b/packages/eslint-config/base.js
@@ -0,0 +1,32 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import turboPlugin from "eslint-plugin-turbo";
+import tseslint from "typescript-eslint";
+import onlyWarn from "eslint-plugin-only-warn";
+
+/**
+ * A shared ESLint configuration for the repository.
+ *
+ * @type {import("eslint").Linter.Config[]}
+ * */
+export const config = [
+ js.configs.recommended,
+ eslintConfigPrettier,
+ ...tseslint.configs.recommended,
+ {
+ plugins: {
+ turbo: turboPlugin,
+ },
+ rules: {
+ "turbo/no-undeclared-env-vars": "warn",
+ },
+ },
+ {
+ plugins: {
+ onlyWarn,
+ },
+ },
+ {
+ ignores: ["dist/**"],
+ },
+];
diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js
new file mode 100644
index 00000000..6bf01a74
--- /dev/null
+++ b/packages/eslint-config/next.js
@@ -0,0 +1,49 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import tseslint from "typescript-eslint";
+import pluginReactHooks from "eslint-plugin-react-hooks";
+import pluginReact from "eslint-plugin-react";
+import globals from "globals";
+import pluginNext from "@next/eslint-plugin-next";
+import { config as baseConfig } from "./base.js";
+
+/**
+ * A custom ESLint configuration for libraries that use Next.js.
+ *
+ * @type {import("eslint").Linter.Config[]}
+ * */
+export const nextJsConfig = [
+ ...baseConfig,
+ js.configs.recommended,
+ eslintConfigPrettier,
+ ...tseslint.configs.recommended,
+ {
+ ...pluginReact.configs.flat.recommended,
+ languageOptions: {
+ ...pluginReact.configs.flat.recommended.languageOptions,
+ globals: {
+ ...globals.serviceworker,
+ },
+ },
+ },
+ {
+ plugins: {
+ "@next/next": pluginNext,
+ },
+ rules: {
+ ...pluginNext.configs.recommended.rules,
+ ...pluginNext.configs["core-web-vitals"].rules,
+ },
+ },
+ {
+ plugins: {
+ "react-hooks": pluginReactHooks,
+ },
+ settings: { react: { version: "detect" } },
+ rules: {
+ ...pluginReactHooks.configs.recommended.rules,
+ // React scope no longer necessary with new JSX transform.
+ "react/react-in-jsx-scope": "off",
+ },
+ },
+];
diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json
new file mode 100644
index 00000000..b085db51
--- /dev/null
+++ b/packages/eslint-config/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@repo/eslint-config",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ "./base": "./base.js",
+ "./next-js": "./next.js",
+ "./react-internal": "./react-internal.js"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.33.0",
+ "@next/eslint-plugin-next": "^15.4.2",
+ "eslint": "^9.33.0",
+ "eslint-config-prettier": "^10.1.1",
+ "eslint-plugin-only-warn": "^1.1.0",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-turbo": "^2.5.0",
+ "globals": "^16.3.0",
+ "typescript": "^5.9.2",
+ "typescript-eslint": "^8.39.0"
+ }
+}
diff --git a/packages/eslint-config/react-internal.js b/packages/eslint-config/react-internal.js
new file mode 100644
index 00000000..daeccba2
--- /dev/null
+++ b/packages/eslint-config/react-internal.js
@@ -0,0 +1,39 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import tseslint from "typescript-eslint";
+import pluginReactHooks from "eslint-plugin-react-hooks";
+import pluginReact from "eslint-plugin-react";
+import globals from "globals";
+import { config as baseConfig } from "./base.js";
+
+/**
+ * A custom ESLint configuration for libraries that use React.
+ *
+ * @type {import("eslint").Linter.Config[]} */
+export const config = [
+ ...baseConfig,
+ js.configs.recommended,
+ eslintConfigPrettier,
+ ...tseslint.configs.recommended,
+ pluginReact.configs.flat.recommended,
+ {
+ languageOptions: {
+ ...pluginReact.configs.flat.recommended.languageOptions,
+ globals: {
+ ...globals.serviceworker,
+ ...globals.browser,
+ },
+ },
+ },
+ {
+ plugins: {
+ "react-hooks": pluginReactHooks,
+ },
+ settings: { react: { version: "detect" } },
+ rules: {
+ ...pluginReactHooks.configs.recommended.rules,
+ // React scope no longer necessary with new JSX transform.
+ "react/react-in-jsx-scope": "off",
+ },
+ },
+];
diff --git a/packages/hooks/package.json b/packages/hooks/package.json
new file mode 100644
index 00000000..918d877f
--- /dev/null
+++ b/packages/hooks/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "@repo/hooks",
+ "version": "0.0.0",
+ "private": true
+} \ No newline at end of file
diff --git a/packages/hooks/tsconfig.json b/packages/hooks/tsconfig.json
new file mode 100644
index 00000000..8381a349
--- /dev/null
+++ b/packages/hooks/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"],
+ "@lib/*": ["../lib/*"]
+ }
+ },
+ "extends": "@total-typescript/tsconfig/bundler/dom/library-monorepo"
+}
diff --git a/packages/hooks/use-keypress.ts b/packages/hooks/use-keypress.ts
new file mode 100644
index 00000000..42906660
--- /dev/null
+++ b/packages/hooks/use-keypress.ts
@@ -0,0 +1,15 @@
+import { useEffect } from "react"
+
+export const useKeyPress = (key: string, callback: () => void) => {
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === key && e.altKey) {
+ callback()
+ }
+ }
+ window.addEventListener("keydown", handler)
+ return () => {
+ window.removeEventListener("keydown", handler)
+ }
+ }, [key, callback])
+}
diff --git a/packages/hooks/use-mobile.ts b/packages/hooks/use-mobile.ts
new file mode 100644
index 00000000..283bbb4c
--- /dev/null
+++ b/packages/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/packages/lib/api.ts b/packages/lib/api.ts
new file mode 100644
index 00000000..3aa00b34
--- /dev/null
+++ b/packages/lib/api.ts
@@ -0,0 +1,208 @@
+import { createFetch, createSchema } from "@better-fetch/fetch"
+import { z } from "zod"
+import {
+ AnalyticsChatResponseSchema,
+ AnalyticsMemoryResponseSchema,
+ AnalyticsUsageResponseSchema,
+ ConnectionResponseSchema,
+ CreateProjectSchema,
+ DeleteProjectResponseSchema,
+ DeleteProjectSchema,
+ DocumentsWithMemoriesQuerySchema,
+ DocumentsWithMemoriesResponseSchema,
+ ListMemoriesResponseSchema,
+ ListProjectsResponseSchema,
+ MemoryAddSchema,
+ MemoryResponseSchema,
+ MigrateMCPRequestSchema,
+ MigrateMCPResponseSchema,
+ ProjectSchema,
+ SearchRequestSchema,
+ SearchResponseSchema,
+ type SearchResult,
+ SettingsRequestSchema,
+} from "../validation/api"
+
+// Settings response schema - this is custom to console (not in shared validation)
+const SettingsResponseSchema = z.object({
+ message: z.string(),
+ settings: z.object({
+ excludeItems: z.array(z.string().min(1).max(20)).optional(),
+ filterPrompt: z.string().min(1).max(750).optional(),
+ includeItems: z.array(z.string().min(1).max(20)).optional(),
+ shouldLLMFilter: z.boolean().optional(),
+ }),
+})
+
+// Analytics request schema - custom to console
+const AnalyticsRequestSchema = z.object({
+ from: z.string().datetime().optional(),
+ limit: z.number().int().min(1).max(100).default(20),
+ page: z.number().int().min(1).default(1),
+ period: z.enum(["24h", "7d", "30d", "all"]).optional(),
+ to: z.string().datetime().optional(),
+})
+
+// Waitlist response schema
+const WaitlistStatusResponseSchema = z.object({
+ inWaitlist: z.boolean(),
+ accessGranted: z.boolean(),
+ createdAt: z.string().datetime(),
+})
+
+export const apiSchema = createSchema({
+ "@get/analytics/chat": {
+ output: AnalyticsChatResponseSchema,
+ query: AnalyticsRequestSchema,
+ },
+ "@get/analytics/memory": {
+ output: AnalyticsMemoryResponseSchema,
+ query: AnalyticsRequestSchema,
+ },
+
+ // Analytics operations
+ "@get/analytics/usage": {
+ output: AnalyticsUsageResponseSchema,
+ query: AnalyticsRequestSchema,
+ },
+
+ // Connection operations - Add missing endpoints
+ "@post/connections/:provider": {
+ input: z.object({
+ containerTags: z.array(z.string()).optional(),
+ documentLimit: z.number().int().min(1).max(10000).optional(),
+ metadata: z
+ .record(z.union([z.string(), z.number(), z.boolean()]))
+ .optional()
+ .nullable(),
+ redirectUrl: z.string().optional(),
+ }),
+ output: z.object({
+ authLink: z.string(),
+ expiresIn: z.string(),
+ id: z.string(),
+ redirectsTo: z.string().optional(),
+ }),
+ params: z.object({
+ provider: z.enum(["google-drive", "notion", "onedrive"]),
+ }),
+ },
+
+ "@post/connections/list": {
+ input: z.object({
+ containerTags: z.array(z.string()).optional(),
+ }),
+ output: z.array(ConnectionResponseSchema),
+ },
+
+ "@get/connections": {
+ output: z.array(ConnectionResponseSchema),
+ query: z
+ .object({
+ endUserId: z.string().optional(),
+ })
+ .optional(),
+ },
+
+ // Connection operations
+ "@get/connections/:connectionId": {
+ output: ConnectionResponseSchema,
+ params: z.object({ connectionId: z.string() }),
+ },
+
+ "@delete/connections/:connectionId": {
+ output: z.object({
+ id: z.string(),
+ provider: z.string(),
+ }),
+ params: z.object({ connectionId: z.string() }),
+ },
+
+ // Settings operations
+ "@get/settings": {
+ output: z.object({ settings: z.object({}).passthrough() }),
+ },
+ "@patch/settings": {
+ input: SettingsRequestSchema,
+ output: SettingsResponseSchema,
+ },
+ // Memory operations
+ "@post/memories": {
+ input: MemoryAddSchema,
+ output: MemoryResponseSchema,
+ },
+ "@post/memories/list": {
+ body: z
+ .object({
+ limit: z.number().optional(),
+ page: z.number().optional(),
+ status: z.string().optional(),
+ containerTags: z.array(z.string()).optional(),
+ })
+ .optional(),
+ output: ListMemoriesResponseSchema,
+ },
+ "@post/memories/documents": {
+ input: DocumentsWithMemoriesQuerySchema,
+ output: DocumentsWithMemoriesResponseSchema,
+ },
+ "@post/memories/documents/by-ids": {
+ input: z.object({
+ ids: z.array(z.string()),
+ by: z.enum(["id", "customId"]).optional(),
+ containerTags: z.array(z.string()).optional(),
+ }),
+ output: DocumentsWithMemoriesResponseSchema,
+ },
+ "@post/memories/migrate-mcp": {
+ input: MigrateMCPRequestSchema,
+ output: MigrateMCPResponseSchema,
+ },
+
+ // Delete a memory
+ "@delete/memories/:id": {
+ output: z.any(), // 204 No-Content
+ params: z.object({ id: z.string() }),
+ },
+
+ // Search operations
+ "@post/search": {
+ input: SearchRequestSchema,
+ output: SearchResponseSchema,
+ },
+
+ // Project operations
+ "@get/projects": {
+ output: ListProjectsResponseSchema,
+ },
+ "@post/projects": {
+ input: CreateProjectSchema,
+ output: ProjectSchema,
+ },
+ "@delete/projects/:projectId": {
+ input: DeleteProjectSchema,
+ output: DeleteProjectResponseSchema,
+ params: z.object({
+ projectId: z.string(),
+ }),
+ },
+
+ // Waitlist operations
+ "@get/waitlist/status": {
+ output: WaitlistStatusResponseSchema,
+ },
+})
+
+export const $fetch = createFetch({
+ baseURL: `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`,
+ credentials: "include",
+ retry: {
+ attempts: 3,
+ delay: 100,
+ type: "linear",
+ },
+ schema: apiSchema,
+})
+
+// Re-export types that might be used elsewhere
+export type { SearchResult }
diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx
new file mode 100644
index 00000000..5b2d58bc
--- /dev/null
+++ b/packages/lib/auth-context.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import {
+ createContext,
+ type ReactNode,
+ useContext,
+ useEffect,
+ useState,
+} from "react"
+import { authClient, useSession } from "./auth"
+
+type Organization = typeof authClient.$Infer.ActiveOrganization
+type SessionData = NonNullable<ReturnType<typeof useSession>["data"]>
+
+interface AuthContextType {
+ session: SessionData["session"] | null
+ user: SessionData["user"] | null
+ org: Organization | null
+ setActiveOrg: (orgSlug: string) => Promise<void>
+}
+
+const AuthContext = createContext<AuthContextType | undefined>(undefined)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const { data: session } = useSession()
+ const [org, setOrg] = useState<Organization | null>(null)
+
+ useEffect(() => {
+ if (session?.session.activeOrganizationId) {
+ authClient.organization.getFullOrganization().then((org) => {
+ setOrg(org)
+ })
+ }
+ }, [session?.session.activeOrganizationId])
+
+ const setActiveOrg = async (slug: string) => {
+ if (!slug) return
+
+ const activeOrg = await authClient.organization.setActive({
+ organizationSlug: slug,
+ })
+ setOrg(activeOrg)
+ }
+
+ return (
+ <AuthContext.Provider
+ value={{
+ org,
+ session: session?.session ?? null,
+ user: session?.user ?? null,
+ setActiveOrg,
+ }}
+ >
+ {children}
+ </AuthContext.Provider>
+ )
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext)
+ if (context === undefined) {
+ throw new Error("useAuth must be used within an AuthProvider")
+ }
+ return context
+}
diff --git a/packages/lib/auth.middleware.ts b/packages/lib/auth.middleware.ts
new file mode 100644
index 00000000..3b1f1f40
--- /dev/null
+++ b/packages/lib/auth.middleware.ts
@@ -0,0 +1,26 @@
+import { createAuthClient } from "better-auth/client"
+import {
+ adminClient,
+ anonymousClient,
+ apiKeyClient,
+ emailOTPClient,
+ magicLinkClient,
+ organizationClient,
+ usernameClient,
+} from "better-auth/client/plugins"
+
+export const middlewareAuthClient = createAuthClient({
+ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai",
+ fetchOptions: {
+ throw: true,
+ },
+ plugins: [
+ usernameClient(),
+ magicLinkClient(),
+ emailOTPClient(),
+ apiKeyClient(),
+ adminClient(),
+ organizationClient(),
+ anonymousClient(),
+ ],
+})
diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts
new file mode 100644
index 00000000..4369bef1
--- /dev/null
+++ b/packages/lib/auth.ts
@@ -0,0 +1,32 @@
+import {
+ adminClient,
+ anonymousClient,
+ apiKeyClient,
+ emailOTPClient,
+ magicLinkClient,
+ organizationClient,
+ usernameClient,
+} from "better-auth/client/plugins"
+import { createAuthClient } from "better-auth/react"
+
+export const authClient = createAuthClient({
+ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai",
+ fetchOptions: {
+ credentials: "include",
+ throw: true,
+ },
+ plugins: [
+ usernameClient(),
+ magicLinkClient(),
+ emailOTPClient(),
+ apiKeyClient(),
+ adminClient(),
+ organizationClient(),
+ anonymousClient(),
+ ],
+})
+
+export const signIn = authClient.signIn
+export const signOut = authClient.signOut
+export const useSession = authClient.useSession
+export const getSession = authClient.getSession
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
new file mode 100644
index 00000000..1bfd1ea8
--- /dev/null
+++ b/packages/lib/constants.ts
@@ -0,0 +1,4 @@
+const BIG_DIMENSIONS_NEW = 1536
+const DEFAULT_PROJECT_ID = "sm_project_default"
+
+export { BIG_DIMENSIONS_NEW, DEFAULT_PROJECT_ID }
diff --git a/packages/lib/error-tracking.tsx b/packages/lib/error-tracking.tsx
new file mode 100644
index 00000000..bf320271
--- /dev/null
+++ b/packages/lib/error-tracking.tsx
@@ -0,0 +1,191 @@
+"use client"
+
+import { usePathname } from "next/navigation"
+import { useEffect } from "react"
+import { useSession } from "./auth"
+import { usePostHog } from "./posthog"
+
+export function useErrorTracking() {
+ const posthog = usePostHog()
+ const { data: session } = useSession()
+ const pathname = usePathname()
+
+ const trackError = (
+ error: Error | unknown,
+ context?: Record<string, any>,
+ ) => {
+ const errorDetails = {
+ error_message: error instanceof Error ? error.message : String(error),
+ error_stack: error instanceof Error ? error.stack : undefined,
+ error_name: error instanceof Error ? error.name : "Unknown",
+ pathname,
+ user_id: session?.user?.id,
+ user_email: session?.user?.email,
+ timestamp: new Date().toISOString(),
+ ...context,
+ }
+
+ posthog.capture("error_occurred", errorDetails)
+ }
+
+ const trackApiError = (
+ error: Error | unknown,
+ endpoint: string,
+ method: string,
+ ) => {
+ trackError(error, {
+ error_type: "api_error",
+ api_endpoint: endpoint,
+ api_method: method,
+ })
+ }
+
+ const trackComponentError = (
+ error: Error | unknown,
+ componentName: string,
+ ) => {
+ trackError(error, {
+ error_type: "component_error",
+ component_name: componentName,
+ })
+ }
+
+ const trackValidationError = (
+ error: Error | unknown,
+ formName: string,
+ field?: string,
+ ) => {
+ trackError(error, {
+ error_type: "validation_error",
+ form_name: formName,
+ field_name: field,
+ })
+ }
+
+ return {
+ trackError,
+ trackApiError,
+ trackComponentError,
+ trackValidationError,
+ }
+}
+
+// Global error boundary component
+export function ErrorTrackingProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const { trackError } = useErrorTracking()
+
+ useEffect(() => {
+ // Global error handler for unhandled errors
+ const handleError = (event: ErrorEvent) => {
+ trackError(event.error, {
+ error_type: "global_error",
+ source: "window_error",
+ filename: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ })
+ }
+
+ // Global handler for unhandled promise rejections
+ const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
+ trackError(event.reason, {
+ error_type: "unhandled_promise_rejection",
+ source: "promise_rejection",
+ })
+ }
+
+ window.addEventListener("error", handleError)
+ window.addEventListener("unhandledrejection", handleUnhandledRejection)
+
+ return () => {
+ window.removeEventListener("error", handleError)
+ window.removeEventListener("unhandledrejection", handleUnhandledRejection)
+ }
+ }, [trackError])
+
+ return <>{children}</>
+}
+
+// Hook for tracking user interactions
+export function useInteractionTracking() {
+ const posthog = usePostHog()
+ const { data: session } = useSession()
+ const pathname = usePathname()
+
+ const trackInteraction = (action: string, details?: Record<string, any>) => {
+ posthog.capture("user_interaction", {
+ action,
+ pathname,
+ user_id: session?.user?.id,
+ timestamp: new Date().toISOString(),
+ ...details,
+ })
+ }
+
+ const trackFormSubmission = (
+ formName: string,
+ success: boolean,
+ details?: Record<string, any>,
+ ) => {
+ posthog.capture("form_submission", {
+ form_name: formName,
+ success,
+ pathname,
+ user_id: session?.user?.id,
+ timestamp: new Date().toISOString(),
+ ...details,
+ })
+ }
+
+ const trackButtonClick = (buttonName: string, context?: string) => {
+ trackInteraction("button_click", {
+ button_name: buttonName,
+ context,
+ })
+ }
+
+ const trackLinkClick = (
+ url: string,
+ linkText?: string,
+ external?: boolean,
+ ) => {
+ trackInteraction("link_click", {
+ url,
+ link_text: linkText,
+ external,
+ })
+ }
+
+ const trackModalOpen = (modalName: string) => {
+ trackInteraction("modal_open", {
+ modal_name: modalName,
+ })
+ }
+
+ const trackModalClose = (modalName: string) => {
+ trackInteraction("modal_close", {
+ modal_name: modalName,
+ })
+ }
+
+ const trackTabChange = (fromTab: string, toTab: string) => {
+ trackInteraction("tab_change", {
+ from_tab: fromTab,
+ to_tab: toTab,
+ })
+ }
+
+ return {
+ trackInteraction,
+ trackFormSubmission,
+ trackButtonClick,
+ trackLinkClick,
+ trackModalOpen,
+ trackModalClose,
+ trackTabChange,
+ }
+}
diff --git a/packages/lib/generate-id.ts b/packages/lib/generate-id.ts
new file mode 100644
index 00000000..bbe201fd
--- /dev/null
+++ b/packages/lib/generate-id.ts
@@ -0,0 +1,6 @@
+import { customAlphabet } from "nanoid"
+
+export const generateId = () =>
+ customAlphabet("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")(
+ 22,
+ )
diff --git a/packages/lib/glass-effect-manager.ts b/packages/lib/glass-effect-manager.ts
new file mode 100644
index 00000000..291a30aa
--- /dev/null
+++ b/packages/lib/glass-effect-manager.ts
@@ -0,0 +1,308 @@
+// Singleton WebGL context manager for glass effects
+class GlassEffectManager {
+ private static instance: GlassEffectManager | null = null
+ private canvas: HTMLCanvasElement | null = null
+ private gl: WebGLRenderingContext | null = null
+ private program: WebGLProgram | null = null
+ private uniforms: Record<string, WebGLUniformLocation | null> = {}
+ private effects: Map<string, EffectInstance> = new Map()
+ private animationFrame: number | null = null
+ private startTime: number = performance.now()
+ private mousePositions: Map<string, { x: number; y: number }> = new Map()
+
+ static getInstance(): GlassEffectManager {
+ if (!GlassEffectManager.instance) {
+ GlassEffectManager.instance = new GlassEffectManager()
+ }
+ return GlassEffectManager.instance
+ }
+
+ private constructor() {
+ this.initializeContext()
+ }
+
+ private initializeContext() {
+ // Create offscreen canvas
+ this.canvas = document.createElement("canvas")
+ this.canvas.width = 1024 // Default size, will be adjusted
+ this.canvas.height = 1024
+
+ this.gl = this.canvas.getContext("webgl", {
+ alpha: true,
+ premultipliedAlpha: false,
+ preserveDrawingBuffer: true,
+ })
+
+ if (!this.gl) {
+ console.error("WebGL not supported")
+ return
+ }
+
+ this.setupShaders()
+ this.startRenderLoop()
+ }
+
+ private setupShaders() {
+ if (!this.gl) return
+
+ const vsSource = `
+ attribute vec2 position;
+ void main() {
+ gl_Position = vec4(position, 0.0, 1.0);
+ }
+ `
+
+ const fsSource = `
+ precision mediump float;
+
+ uniform vec2 iResolution;
+ uniform float iTime;
+ uniform vec2 iMouse;
+ uniform float iExpanded;
+
+ float noise(vec2 p) {
+ return sin(p.x * 10.0) * sin(p.y * 10.0);
+ }
+
+ void main() {
+ vec2 uv = gl_FragCoord.xy / iResolution.xy;
+ vec2 mouse = iMouse / iResolution.xy;
+
+ float dist = length(uv - mouse);
+
+ vec2 distortion = vec2(
+ sin(iTime * 2.0 + uv.y * 10.0) * 0.0025,
+ cos(iTime * 1.5 + uv.x * 10.0) * 0.0025
+ );
+
+ vec2 refractedUV = uv + distortion * (1.0 - dist);
+
+ vec3 glassColor = mix(
+ vec3(0.1, 0.1, 0.12),
+ vec3(0.16, 0.16, 0.19),
+ 1.0 - length(uv - vec2(0.5))
+ );
+
+ float glow = exp(-dist * 5.0) * 0.35;
+ glassColor += vec3(glow);
+
+ float edge = 1.0 - smoothstep(0.0, 0.02, min(
+ min(uv.x, 1.0 - uv.x),
+ min(uv.y, 1.0 - uv.y)
+ ));
+ glassColor += edge * 0.3;
+
+ float edgeGlow = 1.0 - smoothstep(0.0, 0.05, min(
+ min(uv.x, 1.0 - uv.x),
+ min(uv.y, 1.0 - uv.y)
+ ));
+ glassColor += vec3(edgeGlow * 0.1, edgeGlow * 0.1, edgeGlow * 0.2);
+
+ float n = noise(refractedUV * 50.0 + vec2(iTime)) * 0.025;
+ glassColor += vec3(n);
+
+ float alpha = 0.25 + edge * 0.2 + glow * 0.2;
+ alpha *= mix(0.8, 1.0, iExpanded);
+
+ gl_FragColor = vec4(glassColor, alpha);
+ }
+ `
+
+ const createShader = (type: number, source: string) => {
+ const shader = this.gl!.createShader(type)
+ if (!shader) return null
+
+ this.gl!.shaderSource(shader, source)
+ this.gl!.compileShader(shader)
+
+ if (!this.gl!.getShaderParameter(shader, this.gl!.COMPILE_STATUS)) {
+ console.error("Shader error:", this.gl!.getShaderInfoLog(shader))
+ this.gl!.deleteShader(shader)
+ return null
+ }
+ return shader
+ }
+
+ const vs = createShader(this.gl.VERTEX_SHADER, vsSource)
+ const fs = createShader(this.gl.FRAGMENT_SHADER, fsSource)
+ if (!vs || !fs) return
+
+ this.program = this.gl.createProgram()
+ if (!this.program) return
+
+ this.gl.attachShader(this.program, vs)
+ this.gl.attachShader(this.program, fs)
+ this.gl.linkProgram(this.program)
+ // biome-ignore lint/correctness/useHookAtTopLevel: Well, not a hook
+ this.gl.useProgram(this.program)
+
+ // Buffer setup
+ const buffer = this.gl.createBuffer()
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer)
+ this.gl.bufferData(
+ this.gl.ARRAY_BUFFER,
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
+ this.gl.STATIC_DRAW,
+ )
+
+ const position = this.gl.getAttribLocation(this.program, "position")
+ this.gl.enableVertexAttribArray(position)
+ this.gl.vertexAttribPointer(position, 2, this.gl.FLOAT, false, 0, 0)
+
+ // Store uniform locations
+ this.uniforms = {
+ resolution: this.gl.getUniformLocation(this.program, "iResolution"),
+ time: this.gl.getUniformLocation(this.program, "iTime"),
+ mouse: this.gl.getUniformLocation(this.program, "iMouse"),
+ expanded: this.gl.getUniformLocation(this.program, "iExpanded"),
+ }
+
+ // Enable blending
+ this.gl.enable(this.gl.BLEND)
+ this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA)
+ }
+
+ registerEffect(
+ id: string,
+ targetCanvas: HTMLCanvasElement,
+ isExpanded: boolean,
+ ): () => void {
+ // Ensure minimum dimensions
+ const width = Math.max(1, targetCanvas.width)
+ const height = Math.max(1, targetCanvas.height)
+
+ const effect: EffectInstance = {
+ id,
+ targetCanvas,
+ isExpanded,
+ width,
+ height,
+ }
+
+ this.effects.set(id, effect)
+ this.mousePositions.set(id, { x: 0, y: 0 })
+
+ // Return cleanup function
+ return () => {
+ this.effects.delete(id)
+ this.mousePositions.delete(id)
+ if (this.effects.size === 0 && this.animationFrame) {
+ cancelAnimationFrame(this.animationFrame)
+ this.animationFrame = null
+ }
+ }
+ }
+
+ updateMousePosition(id: string, x: number, y: number) {
+ this.mousePositions.set(id, { x, y })
+ }
+
+ updateExpanded(id: string, isExpanded: boolean) {
+ const effect = this.effects.get(id)
+ if (effect) {
+ effect.isExpanded = isExpanded
+ }
+ }
+
+ updateSize(id: string, width: number, height: number) {
+ const effect = this.effects.get(id)
+ if (effect) {
+ // Ensure minimum dimensions
+ effect.width = Math.max(1, width)
+ effect.height = Math.max(1, height)
+ }
+ }
+
+ private startRenderLoop() {
+ const render = () => {
+ if (!this.gl || !this.program || this.effects.size === 0) {
+ this.animationFrame = requestAnimationFrame(render)
+ return
+ }
+
+ const currentTime = (performance.now() - this.startTime) / 1000
+
+ // Render each effect
+ for (const [id, effect] of Array.from(this.effects)) {
+ const mousePos = this.mousePositions.get(id) || { x: 0, y: 0 }
+
+ // Skip rendering if dimensions are invalid
+ if (effect.width <= 0 || effect.height <= 0) {
+ continue
+ }
+
+ // Set canvas size if needed
+ if (
+ this.canvas!.width !== effect.width ||
+ this.canvas!.height !== effect.height
+ ) {
+ this.canvas!.width = effect.width
+ this.canvas!.height = effect.height
+ this.gl.viewport(0, 0, effect.width, effect.height)
+ }
+
+ // Clear and render
+ this.gl.clearColor(0, 0, 0, 0)
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT)
+
+ // Set uniforms
+ if (this.uniforms.resolution) {
+ this.gl.uniform2f(
+ this.uniforms.resolution,
+ effect.width,
+ effect.height,
+ )
+ }
+ if (this.uniforms.time) {
+ this.gl.uniform1f(this.uniforms.time, currentTime)
+ }
+ if (this.uniforms.mouse) {
+ this.gl.uniform2f(this.uniforms.mouse, mousePos.x, mousePos.y)
+ }
+ if (this.uniforms.expanded) {
+ this.gl.uniform1f(
+ this.uniforms.expanded,
+ effect.isExpanded ? 1.0 : 0.0,
+ )
+ }
+
+ // Draw
+ this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
+
+ // Copy to target canvas
+ const targetCtx = effect.targetCanvas.getContext("2d")
+ if (targetCtx) {
+ targetCtx.clearRect(0, 0, effect.width, effect.height)
+ targetCtx.drawImage(this.canvas!, 0, 0)
+ }
+ }
+
+ this.animationFrame = requestAnimationFrame(render)
+ }
+
+ render()
+ }
+
+ // Clean up method (optional, for when the app unmounts)
+ destroy() {
+ if (this.animationFrame) {
+ cancelAnimationFrame(this.animationFrame)
+ }
+ if (this.gl && this.program) {
+ this.gl.deleteProgram(this.program)
+ }
+ this.effects.clear()
+ this.mousePositions.clear()
+ GlassEffectManager.instance = null
+ }
+}
+
+interface EffectInstance {
+ id: string
+ targetCanvas: HTMLCanvasElement
+ isExpanded: boolean
+ width: number
+ height: number
+}
+
+export default GlassEffectManager
diff --git a/packages/lib/package.json b/packages/lib/package.json
new file mode 100644
index 00000000..dcd84902
--- /dev/null
+++ b/packages/lib/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@repo/lib",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@ai-sdk/anthropic": "^1.2.12",
+ "@ai-sdk/google": "^1.2.22",
+ "@ai-sdk/groq": "^1.2.9",
+ "ai-gateway-provider": "^0.0.11"
+ }
+}
diff --git a/packages/lib/posthog.tsx b/packages/lib/posthog.tsx
new file mode 100644
index 00000000..ac563aae
--- /dev/null
+++ b/packages/lib/posthog.tsx
@@ -0,0 +1,84 @@
+"use client"
+
+import { usePathname, useSearchParams } from "next/navigation"
+import posthog from "posthog-js"
+import { Suspense, useEffect } from "react"
+import { useSession } from "./auth"
+
+function PostHogPageTracking() {
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+
+ // Page tracking
+ useEffect(() => {
+ if (pathname) {
+ let url = window.origin + pathname
+ if (searchParams.toString()) {
+ url = `${url}?${searchParams.toString()}`
+ }
+
+ // Extract page context for better tracking
+ const pageContext = {
+ $current_url: url,
+ path: pathname,
+ search_params: searchParams.toString(),
+ page_type: getPageType(pathname),
+ org_slug: getOrgSlug(pathname),
+ }
+
+ posthog.capture("$pageview", pageContext)
+ }
+ }, [pathname, searchParams])
+
+ return null
+}
+
+export function PostHogProvider({ children }: { children: React.ReactNode }) {
+ const { data: session } = useSession()
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY ?? "", {
+ api_host: process.env.NEXT_PUBLIC_BACKEND_URL + "/orange",
+ ui_host: "https://us.i.posthog.com",
+ person_profiles: "identified_only",
+ capture_pageview: false,
+ capture_pageleave: true,
+ })
+ }
+ }, [])
+
+ // User identification
+ useEffect(() => {
+ if (session?.user) {
+ posthog.identify(session.user.id, {
+ email: session.user.email,
+ name: session.user.name,
+ userId: session.user.id,
+ createdAt: session.user.createdAt,
+ })
+ }
+ }, [session?.user])
+
+ return (
+ <>
+ <Suspense fallback={null}>
+ <PostHogPageTracking />
+ </Suspense>
+ {children}
+ </>
+ )
+}
+
+function getPageType(pathname: string): string {
+ return "other"
+}
+
+function getOrgSlug(pathname: string): string | null {
+ const match = pathname.match(/^\/([^/]+)\//)
+ return match ? (match[1] ?? null) : null
+}
+
+export function usePostHog() {
+ return posthog
+}
diff --git a/packages/lib/queries.ts b/packages/lib/queries.ts
new file mode 100644
index 00000000..36338445
--- /dev/null
+++ b/packages/lib/queries.ts
@@ -0,0 +1,86 @@
+import { useQuery } from "@tanstack/react-query"
+import type { useCustomer } from "autumn-js/react"
+
+export const fetchSubscriptionStatus = (
+ autumn: ReturnType<typeof useCustomer>,
+) =>
+ useQuery({
+ queryFn: async () => {
+ const allPlans = [
+ "pro",
+ "memory_starter",
+ "memory_growth",
+ "consumer_pro",
+ ]
+ const statusMap: Record<string, boolean | null> = {}
+
+ await Promise.all(
+ allPlans.map(async (plan) => {
+ try {
+ const res = await autumn.check({
+ productId: plan,
+ })
+ statusMap[plan] = res.data?.allowed ?? false
+ } catch (error) {
+ console.error(`Error checking status for ${plan}:`, error)
+ statusMap[plan] = false
+ }
+ }),
+ )
+
+ return statusMap
+ },
+ queryKey: ["subscription-status"],
+ refetchInterval: 5000, // Refetch every 5 seconds
+ staleTime: 4000, // Consider data stale after 4 seconds
+ })
+
+// Feature checks
+export const fetchMemoriesFeature = (autumn: ReturnType<typeof useCustomer>) =>
+ useQuery({
+ queryFn: async () => {
+ const res = await autumn.check({ featureId: "memories" })
+ return res.data
+ },
+ queryKey: ["autumn-feature", "memories"],
+ staleTime: 30 * 1000, // 30 seconds
+ gcTime: 5 * 60 * 1000, // 5 minutes
+ })
+
+export const fetchConnectionsFeature = (
+ autumn: ReturnType<typeof useCustomer>,
+) =>
+ useQuery({
+ queryFn: async () => {
+ const res = await autumn.check({ featureId: "connections" })
+ return res.data
+ },
+ queryKey: ["autumn-feature", "connections"],
+ staleTime: 30 * 1000, // 30 seconds
+ gcTime: 5 * 60 * 1000, // 5 minutes
+ })
+
+// Product checks
+export const fetchConsumerProProduct = (
+ autumn: ReturnType<typeof useCustomer>,
+) =>
+ useQuery({
+ queryFn: async () => {
+ const res = await autumn.check({ productId: "consumer_pro" })
+ return res.data
+ },
+ queryKey: ["autumn-product", "consumer_pro"],
+ staleTime: 30 * 1000, // 30 seconds
+ gcTime: 5 * 60 * 1000, // 5 minutes
+ })
+
+export const fetchProProduct = (autumn: ReturnType<typeof useCustomer>) =>
+ useQuery({
+ queryFn: async () => {
+ const res = await autumn.check({ productId: "pro" })
+ return res.data
+ },
+ queryKey: ["autumn-product", "pro"],
+ staleTime: 30 * 1000, // 30 seconds
+ gcTime: 5 * 60 * 1000, // 5 minutes
+ })
diff --git a/packages/lib/query-client.tsx b/packages/lib/query-client.tsx
new file mode 100644
index 00000000..eb9c5a21
--- /dev/null
+++ b/packages/lib/query-client.tsx
@@ -0,0 +1,27 @@
+"use client"
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
+import { useState } from "react"
+
+export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchIntervalInBackground: false,
+ refetchOnWindowFocus: false,
+ staleTime: 60 * 1000,
+ },
+ },
+ }),
+ )
+
+ return (
+ <QueryClientProvider client={queryClient}>
+ {children}
+ <ReactQueryDevtools initialIsOpen={false} />
+ </QueryClientProvider>
+ )
+}
diff --git a/packages/lib/similarity.ts b/packages/lib/similarity.ts
new file mode 100644
index 00000000..09d3a2cc
--- /dev/null
+++ b/packages/lib/similarity.ts
@@ -0,0 +1,115 @@
+// Utility functions for calculating semantic similarity between documents and memories
+
+/**
+ * Calculate cosine similarity between two normalized vectors (unit vectors)
+ * Since all embeddings in this system are normalized using normalizeEmbeddingFast,
+ * cosine similarity equals dot product for unit vectors.
+ */
+export const cosineSimilarity = (
+ vectorA: number[],
+ vectorB: number[],
+): number => {
+ if (vectorA.length !== vectorB.length) {
+ throw new Error("Vectors must have the same length")
+ }
+
+ let dotProduct = 0
+
+ for (let i = 0; i < vectorA.length; i++) {
+ const vectorAi = vectorA[i]
+ const vectorBi = vectorB[i]
+ if (
+ typeof vectorAi !== "number" ||
+ typeof vectorBi !== "number" ||
+ isNaN(vectorAi) ||
+ isNaN(vectorBi)
+ ) {
+ throw new Error("Vectors must contain only numbers")
+ }
+ dotProduct += vectorAi * vectorBi
+ }
+
+ return dotProduct
+}
+
+/**
+ * Calculate semantic similarity between two documents
+ * Returns a value between 0 and 1, where 1 is most similar
+ */
+export const calculateSemanticSimilarity = (
+ document1Embedding: number[] | null,
+ document2Embedding: number[] | null,
+): number => {
+ // If we have both embeddings, use cosine similarity
+ if (
+ document1Embedding &&
+ document2Embedding &&
+ document1Embedding.length > 0 &&
+ document2Embedding.length > 0
+ ) {
+ const similarity = cosineSimilarity(document1Embedding, document2Embedding)
+ // Convert from [-1, 1] to [0, 1] range
+ return similarity >= 0 ? similarity : 0
+ }
+
+ return 0
+}
+
+/**
+ * Calculate semantic similarity between a document and memory entry
+ * Returns a value between 0 and 1, where 1 is most similar
+ */
+export const calculateDocumentMemorySimilarity = (
+ documentEmbedding: number[] | null,
+ memoryEmbedding: number[] | null,
+ relevanceScore?: number | null,
+): number => {
+ // If we have both embeddings, use cosine similarity
+ if (
+ documentEmbedding &&
+ memoryEmbedding &&
+ documentEmbedding.length > 0 &&
+ memoryEmbedding.length > 0
+ ) {
+ const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding)
+ // Convert from [-1, 1] to [0, 1] range
+ return similarity >= 0 ? similarity : 0
+ }
+
+ // Fall back to relevance score from database (0-100 scale)
+ if (relevanceScore !== null && relevanceScore !== undefined) {
+ return Math.max(0, Math.min(1, relevanceScore / 100))
+ }
+
+ // Default similarity for connections without embeddings or relevance scores
+ return 0.5
+}
+
+/**
+ * Get visual properties for connection based on similarity
+ */
+export const getConnectionVisualProps = (similarity: number) => {
+ // Ensure similarity is between 0 and 1
+ const normalizedSimilarity = Math.max(0, Math.min(1, similarity))
+
+ return {
+ opacity: Math.max(0, normalizedSimilarity), // 0 to 1 range
+ thickness: Math.max(1, normalizedSimilarity * 4), // 1 to 4 pixels
+ glow: normalizedSimilarity * 0.6, // Glow intensity
+ pulseDuration: 2000 + (1 - normalizedSimilarity) * 3000, // Faster pulse for higher similarity
+ }
+}
+
+/**
+ * Generate magical color based on similarity and connection type
+ */
+export const getMagicalConnectionColor = (
+ similarity: number,
+ hue = 220,
+): string => {
+ const normalizedSimilarity = Math.max(0, Math.min(1, similarity))
+ const saturation = 60 + normalizedSimilarity * 40 // 60% to 100%
+ const lightness = 40 + normalizedSimilarity * 30 // 40% to 70%
+
+ return `hsl(${hue}, ${saturation}%, ${lightness}%)`
+}
diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json
new file mode 100644
index 00000000..f34958f0
--- /dev/null
+++ b/packages/lib/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@total-typescript/tsconfig/bundler/dom/library-monorepo",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"],
+ "@lib/*": ["./*"]
+ }
+ }
+}
diff --git a/packages/lib/utils.ts b/packages/lib/utils.ts
new file mode 100644
index 00000000..02a2ddaf
--- /dev/null
+++ b/packages/lib/utils.ts
@@ -0,0 +1,8 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export const isSelfHosted = process.env.NEXT_PUBLIC_HOST_ID !== "supermemory"
diff --git a/packages/shared/icons.tsx b/packages/shared/icons.tsx
deleted file mode 100644
index 79f54b3e..00000000
--- a/packages/shared/icons.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-export const MemoryIcon: React.FC<React.SVGAttributes<SVGElement>> = (props) => (
- <svg
- viewBox="0 0 89 53"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- width="24"
- height="24"
- {...props}
- >
- <rect
- x="0.40697"
- y="8.52821"
- width="43.0286"
- height="43.0286"
- rx="5.5"
- transform="rotate(-12 0.40697 8.52821)"
- fill="var(--gray-10)"
- stroke="var(--gray-12)"
- />
- <rect
- x="20.8257"
- y="9.19775"
- width="43"
- height="43"
- rx="5.5"
- fill="var(--gray-10)"
- stroke="var(--gray-12)"
- />
- <rect
- x="47.6965"
- y="-0.612372"
- width="43.0286"
- height="43.0286"
- rx="5.5"
- transform="rotate(15 47.6965 -0.612372)"
- fill="var(--gray-10)"
- stroke="var(--gray-12)"
- />
- </svg>
-);
-
-export const SpaceIcon: React.FC<React.SVGAttributes<SVGElement>> = (props) => (
- <svg viewBox="0 0 34 30" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
- <rect
- x="1.39502"
- y="5.2229"
- width="24"
- height="24"
- rx="5.5"
- fill="var(--gray-10)"
- stroke="var(--gray-5)"
- />
- <rect
- x="11.2231"
- y="-0.157702"
- width="24"
- height="24"
- rx="5.5"
- transform="rotate(20 11.2231 -0.157702)"
- fill="var(--gray-10)"
- stroke="var(--gray-5)"
- />
- </svg>
-);
diff --git a/packages/shared/package.json b/packages/shared/package.json
deleted file mode 100644
index f23a22b3..00000000
--- a/packages/shared/package.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "name": "@supermemory/shared",
- "version": "0.0.1",
- "exports": {
- ".": "./utils.ts",
- "./types": "./types.ts",
- "./icons": "./icons.tsx"
- },
- "dependencies": {
- "ai": "^4.0.18"
- }
-}
diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json
deleted file mode 100644
index d34e3f40..00000000
--- a/packages/shared/tsconfig.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "compilerOptions": {
- "target": "es2022",
- "module": "esnext",
- "moduleResolution": "bundler",
- "jsx": "react-jsx",
- "strict": true,
- "skipLibCheck": true,
- "esModuleInterop": true,
- "isolatedModules": true,
- "forceConsistentCasingInFileNames": true,
- "lib": ["es2022", "dom"],
- "types": ["@cloudflare/workers-types"]
- }
-}
diff --git a/packages/shared/types.ts b/packages/shared/types.ts
deleted file mode 100644
index eece18d8..00000000
--- a/packages/shared/types.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-export interface User {
- object: 'user';
- id: string;
- email: string;
- emailVerified: boolean;
- profilePictureUrl: string | null;
- firstName: string | null;
- lastName: string | null;
- createdAt: string;
- updatedAt: string;
-}
-export interface UserResponse {
- object: 'user';
- id: string;
- email: string;
- email_verified: boolean;
- profile_picture_url: string | null;
- first_name: string | null;
- last_name: string | null;
- created_at: string;
- updated_at: string;
-}
diff --git a/packages/shared/utils.ts b/packages/shared/utils.ts
deleted file mode 100644
index 4e3ec20b..00000000
--- a/packages/shared/utils.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { customAlphabet } from "nanoid";
-
-import {
- CoreMessage,
- CoreToolMessage,
- ToolInvocation,
- Message,
- FilePart,
- ImagePart,
- Attachment,
- DataContent,
-} from "ai";
-
-export const nanoid = customAlphabet(
- "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
-);
-export const randomId = () => nanoid(10);
-
-function addToolMessageToChat({
- toolMessage,
- messages,
-}: {
- toolMessage: CoreToolMessage;
- messages: Array<Message>;
-}): Array<Message> {
- return messages.map((message) => {
- if (message.toolInvocations) {
- return {
- ...message,
- toolInvocations: message.toolInvocations.map((toolInvocation) => {
- const toolResult = toolMessage.content.find(
- (tool) => tool.toolCallId === toolInvocation.toolCallId
- );
-
- if (toolResult) {
- return {
- ...toolInvocation,
- state: "result",
- result: toolResult.result,
- };
- }
-
- return toolInvocation;
- }),
- };
- }
-
- return message;
- });
-}
-
-export const coreMessageAttachmentTypes = (
- message: CoreMessage
-): Array<"file" | "image"> => {
- if (typeof message.content === "string") {
- return [];
- }
-
- const attachmentTypes = message.content
- .filter(
- (content): content is FilePart | ImagePart =>
- content.type === "file" || content.type === "image"
- )
- .map((content) => content.type);
-
- return attachmentTypes;
-};
-
-type GenericPart<T extends "file" | "image"> = T extends "file"
- ? FilePart
- : ImagePart;
-
-export const getCoreMessageAttachments = (
- message: CoreMessage
-): Array<Attachment> => {
- const attachmentTypes = coreMessageAttachmentTypes(message);
- if (attachmentTypes.length === 0) {
- return [];
- }
-
- if (typeof message.content === "string") {
- return [];
- }
-
- const attachments: Array<Attachment> = [];
-
- for (let i = 0; i < attachmentTypes.length; i++) {
- const attachmentType = attachmentTypes[i];
- const messageParts = message.content;
-
- const parts = messageParts.filter(
- (part) => part.type === attachmentType
- ) as Array<GenericPart<typeof attachmentType>>;
-
- let data: string | Uint8Array | ArrayBuffer | URL;
-
- if (attachmentType === "file") {
- data = (parts[0] as FilePart).data;
- } else {
- data = (parts[0] as ImagePart).image;
- }
-
- const normalisedData = typeof data === "string" ? data : data.toString();
-
- const size = normalisedData.length;
- attachments.push({
- name: size.toString(),
- url: normalisedData,
- contentType: parts[0].mimeType ?? "image/jpeg",
- });
- }
- return attachments;
-};
-
-export function convertToUIMessages(
- messages: Array<CoreMessage>
-): Array<Message> {
- return messages.reduce((chatMessages: Array<Message>, message) => {
- if (message.role === "tool") {
- return addToolMessageToChat({
- toolMessage: message as CoreToolMessage,
- messages: chatMessages,
- }) satisfies Message[];
- }
-
- let textContent = "";
- let toolInvocations: Array<ToolInvocation> = [];
-
- if (typeof message.content === "string") {
- textContent = message.content.trim();
- } else if (Array.isArray(message.content)) {
- for (const content of message.content) {
- if (content.type === "text") {
- textContent += content.text.trim();
- } else if (content.type === "tool-call") {
- toolInvocations.push({
- state: "call",
- toolCallId: content.toolCallId,
- toolName: content.toolName,
- args: content.args,
- });
- }
- }
- }
-
- // Only add message if it has content or tool invocations
- if (textContent || toolInvocations.length > 0) {
- chatMessages.push({
- id: (message.content.length * 100).toString(),
- role: message.role,
- content: textContent.replace(/<context>([\s\S]*?)<\/context>/, ""),
- toolInvocations,
- experimental_attachments: getCoreMessageAttachments(message),
- annotations: textContent.includes("<context>")
- ? JSON.parse(
- textContent.match(/<context>([\s\S]*?)<\/context>/)?.[1] ?? ""
- )
- : undefined,
- });
- }
-
- return chatMessages;
- }, []);
-}
diff --git a/packages/typescript-config/base.json b/packages/typescript-config/base.json
new file mode 100644
index 00000000..5117f2a3
--- /dev/null
+++ b/packages/typescript-config/base.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "incremental": false,
+ "isolatedModules": true,
+ "lib": ["es2022", "DOM", "DOM.Iterable"],
+ "module": "NodeNext",
+ "moduleDetection": "force",
+ "moduleResolution": "NodeNext",
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022"
+ }
+}
diff --git a/packages/typescript-config/nextjs.json b/packages/typescript-config/nextjs.json
new file mode 100644
index 00000000..e6defa48
--- /dev/null
+++ b/packages/typescript-config/nextjs.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./base.json",
+ "compilerOptions": {
+ "plugins": [{ "name": "next" }],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowJs": true,
+ "jsx": "preserve",
+ "noEmit": true
+ }
+}
diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json
new file mode 100644
index 00000000..27c0e604
--- /dev/null
+++ b/packages/typescript-config/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@repo/typescript-config",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/typescript-config/react-library.json b/packages/typescript-config/react-library.json
new file mode 100644
index 00000000..c3a1b26f
--- /dev/null
+++ b/packages/typescript-config/react-library.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx"
+ }
+}
diff --git a/packages/ui/assets/Logo.tsx b/packages/ui/assets/Logo.tsx
new file mode 100644
index 00000000..8e6b209e
--- /dev/null
+++ b/packages/ui/assets/Logo.tsx
@@ -0,0 +1,51 @@
+export const Logo = ({
+ className,
+ id,
+}: {
+ className?: string
+ id?: string
+}) => {
+ return (
+ <svg
+ className={className}
+ fill="none"
+ id={id}
+ viewBox="0 0 206 168"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>supermemory Logo</title>
+ <path
+ d="M205.864 66.263h-76.401V0h-24.684v71.897c0 7.636 3.021 14.97 8.391 20.373l62.383 62.777 17.454-17.564-46.076-46.365h58.948v-24.84l-.015-.015ZM12.872 30.517l46.075 46.365H0v24.84h76.4v66.264h24.685V96.089c0-7.637-3.021-14.97-8.39-20.374l-62.37-62.762-17.453 17.564Z"
+ fill="#EFEFEF"
+ />
+ </svg>
+ )
+}
+
+export const LogoFull = ({
+ className,
+ id,
+}: {
+ className?: string
+ id?: string
+}) => {
+ return (
+ <svg
+ className={className}
+ fill="none"
+ id={id}
+ viewBox="0 0 1476 168"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>supermemory Logo</title>
+ <g clipPath="url(#a)" fill="#EFEFEF">
+ <path d="M330.13 132.123c-11.97 0-21.792-2.607-29.438-7.823-7.66-5.216-12.284-12.665-13.888-22.362l21.477-5.59c.859 4.351 2.319 7.766 4.353 10.244 2.033 2.493 4.567 4.251 7.588 5.317 3.021 1.052 6.329 1.585 9.908 1.585 5.427 0 9.436-.966 12.027-2.882 2.592-1.931 3.895-4.308 3.895-7.175 0-2.868-1.231-5.058-3.709-6.614-2.477-1.556-6.414-2.824-11.855-3.818l-5.183-.936c-6.414-1.24-12.285-2.954-17.582-5.13-5.312-2.175-9.565-5.187-12.772-9.034-3.207-3.847-4.811-8.817-4.811-14.898 0-9.192 3.336-16.238 9.994-21.151 6.672-4.899 15.421-7.363 26.288-7.363 10.237 0 18.756 2.306 25.543 6.887 6.787 4.597 11.226 10.62 13.33 18.068l-21.663 6.7c-.988-4.711-2.992-8.068-6.013-10.057-3.022-1.988-6.759-2.982-11.197-2.982-4.439 0-7.846.778-10.18 2.334-2.348 1.556-3.522 3.703-3.522 6.426 0 2.982 1.231 5.187 3.708 6.613 2.463 1.427 5.799 2.522 9.994 3.257l5.183.936c6.916 1.24 13.173 2.882 18.785 4.942 5.613 2.046 10.051 4.971 13.33 8.76 3.265 3.79 4.911 8.919 4.911 15.374 0 9.682-3.493 17.175-10.466 22.448-6.973 5.288-16.323 7.924-28.049 7.924h.014ZM409.294 131.749c-7.159 0-13.416-1.643-18.785-4.942-5.369-3.285-9.536-7.853-12.499-13.688-2.964-5.835-4.439-12.549-4.439-20.114v-55.14h23.324v53.282c0 6.959 1.69 12.174 5.097 15.647 3.394 3.472 8.233 5.216 14.533 5.216 7.159 0 12.714-2.392 16.666-7.176 3.952-4.783 5.928-11.454 5.928-20.027V37.865h23.324v92.4h-22.952v-12.103h-3.336c-1.475 3.112-4.252 6.152-8.333 9.135-4.066 2.982-10.252 4.466-18.513 4.466l-.015-.014ZM479.095 167.525V37.865h22.952v11.18h3.336c2.09-3.601 5.369-6.8 9.807-9.595 4.439-2.795 10.796-4.193 19.072-4.193 7.402 0 14.261 1.83 20.546 5.49 6.3 3.66 11.355 9.034 15.177 16.108 3.823 7.074 5.742 15.647 5.742 25.704v2.983c0 10.057-1.919 18.63-5.742 25.704-3.822 7.074-8.891 12.449-15.177 16.108-6.3 3.66-13.144 5.49-20.546 5.49-5.555 0-10.209-.648-13.974-1.96-3.766-1.296-6.787-2.982-9.078-5.028-2.291-2.046-4.109-4.121-5.455-6.239h-3.336v47.879h-23.324v.029Zm48.137-55.141c7.288 0 13.301-2.334 18.055-6.988 4.753-4.654 7.13-11.454 7.13-20.402v-1.859c0-8.947-2.405-15.748-7.216-20.402-4.811-4.653-10.796-6.987-17.955-6.987-7.159 0-13.144 2.334-17.955 6.987-4.81 4.654-7.216 11.455-7.216 20.402v1.86c0 8.947 2.406 15.747 7.216 20.401 4.811 4.654 10.796 6.988 17.955 6.988h-.014ZM629.792 132.873c-9.135 0-17.182-1.96-24.155-5.864-6.973-3.919-12.399-9.438-16.294-16.584-3.88-7.147-5.827-15.561-5.827-25.243v-2.234c0-9.682 1.904-18.096 5.741-25.243 3.823-7.146 9.193-12.665 16.108-16.584 6.916-3.904 14.934-5.864 24.069-5.864s16.852 2.017 23.51 6.051c6.658 4.035 11.855 9.625 15.549 16.772 3.709 7.146 5.556 15.43 5.556 24.868v8.011h-66.837c.244 6.34 2.592 11.484 7.03 15.46 4.439 3.977 9.88 5.965 16.294 5.965 6.415 0 11.354-1.426 14.433-4.279 3.078-2.853 5.426-6.023 7.03-9.495l19.071 10.057c-1.732 3.227-4.223 6.743-7.502 10.532-3.279 3.79-7.617 7.017-13.058 9.683-5.427 2.665-12.342 4.005-20.733 4.005l.015-.014Zm-22.408-59.434h42.954c-.501-5.346-2.62-9.625-6.386-12.852-3.765-3.228-8.677-4.842-14.719-4.842s-11.297 1.614-14.991 4.842c-3.708 3.227-5.985 7.52-6.844 12.852h-.014ZM685.517 130.265v-92.4h22.952v10.431h3.336c1.36-3.731 3.608-6.454 6.758-8.198 3.15-1.743 6.816-2.608 11.011-2.608h11.111v20.863h-11.483c-5.928 0-10.796 1.585-14.619 4.755-3.823 3.17-5.742 8.04-5.742 14.624v52.533h-23.324ZM749.562 130.265v-92.4h22.951v10.057h3.337c1.603-3.098 4.252-5.807 7.96-8.098 3.709-2.29 8.577-3.443 14.619-3.443 6.543 0 11.784 1.268 15.736 3.818 3.951 2.55 6.972 5.864 9.077 9.97h3.336c2.091-3.976 5.055-7.261 8.892-9.87 3.823-2.607 9.249-3.904 16.294-3.904 5.67 0 10.824 1.21 15.463 3.631 4.625 2.42 8.333 6.08 11.111 10.993 2.778 4.914 4.166 11.08 4.166 18.544v60.731H859.18v-59.06c0-5.086-1.303-8.904-3.88-11.454-2.591-2.55-6.242-3.819-10.924-3.819-5.312 0-9.407 1.715-12.314 5.13-2.906 3.415-4.353 8.285-4.353 14.624v54.593h-23.324v-59.06c0-5.085-1.303-8.904-3.88-11.454-2.591-2.55-6.242-3.818-10.924-3.818-5.312 0-9.407 1.715-12.314 5.13-2.906 3.414-4.353 8.284-4.353 14.624v54.592H749.59l-.028-.057ZM940.249 132.873c-9.135 0-17.182-1.96-24.155-5.864-6.972-3.919-12.399-9.438-16.293-16.584-3.881-7.147-5.828-15.561-5.828-25.243v-2.234c0-9.682 1.904-18.096 5.742-25.243 3.823-7.146 9.192-12.665 16.107-16.584 6.916-3.904 14.934-5.864 24.069-5.864s16.852 2.017 23.51 6.051c6.658 4.035 11.855 9.625 15.549 16.772 3.709 7.146 5.556 15.43 5.556 24.868v8.011h-66.837c.244 6.34 2.592 11.484 7.031 15.46 4.438 3.977 9.879 5.965 16.294 5.965 6.414 0 11.354-1.426 14.446-4.279 3.079-2.853 5.427-6.023 7.031-9.495l19.071 10.057c-1.732 3.227-4.224 6.743-7.503 10.532-3.278 3.79-7.617 7.017-13.058 9.683-5.426 2.665-12.342 4.005-20.732 4.005v-.014Zm-22.393-59.434h42.954c-.502-5.346-2.621-9.625-6.386-12.852-3.766-3.228-8.677-4.842-14.719-4.842s-11.297 1.614-14.991 4.842c-3.708 3.227-5.985 7.52-6.844 12.852h-.014ZM995.975 130.265v-92.4h22.955v10.057h3.33c1.61-3.098 4.25-5.807 7.96-8.098 3.71-2.29 8.58-3.443 14.62-3.443 6.55 0 11.79 1.268 15.74 3.818 3.95 2.55 6.97 5.864 9.08 9.97h3.33c2.09-3.976 5.06-7.261 8.89-9.87 3.83-2.607 9.25-3.904 16.3-3.904 5.67 0 10.82 1.21 15.46 3.631 4.62 2.42 8.33 6.08 11.11 10.993 2.78 4.914 4.17 11.08 4.17 18.544v60.731h-23.33v-59.06c0-5.086-1.3-8.904-3.88-11.454-2.59-2.55-6.24-3.819-10.92-3.819-5.31 0-9.41 1.715-12.32 5.13-2.9 3.415-4.35 8.285-4.35 14.624v54.593h-23.32v-59.06c0-5.085-1.3-8.904-3.88-11.454-2.59-2.55-6.24-3.818-10.93-3.818-5.31 0-9.4 1.715-12.31 5.13-2.91 3.414-4.35 8.284-4.35 14.624v54.592h-23.327l-.028-.057ZM1188.52 132.873c-9.13 0-17.34-1.859-24.62-5.591-7.29-3.731-13.03-9.134-17.23-16.209-4.19-7.074-6.3-15.59-6.3-25.517v-2.982c0-9.942 2.09-18.443 6.3-25.517 4.2-7.075 9.94-12.478 17.23-16.21 7.27-3.731 15.49-5.59 24.62-5.59 9.14 0 17.34 1.859 24.63 5.59 7.27 3.732 13.02 9.135 17.22 16.21 4.2 7.074 6.29 15.59 6.29 25.517v2.982c0 9.942-2.1 18.443-6.29 25.517-4.19 7.075-9.93 12.478-17.22 16.209-7.29 3.732-15.49 5.591-24.63 5.591Zm0-20.863c7.16 0 13.08-2.335 17.77-6.988 4.7-4.654 7.03-11.34 7.03-20.028v-1.859c0-8.688-2.32-15.373-6.94-20.027-4.63-4.654-10.58-6.988-17.87-6.988-7.29 0-13.09 2.334-17.77 6.988-4.7 4.654-7.03 11.34-7.03 20.027v1.86c0 8.687 2.33 15.373 7.03 20.027 4.7 4.653 10.61 6.988 17.77 6.988h.01ZM1248.87 130.265v-92.4h22.96v10.431h3.33c1.36-3.731 3.61-6.454 6.76-8.198 3.15-1.743 6.81-2.608 11.01-2.608h11.11v20.863h-11.48c-5.93 0-10.8 1.585-14.62 4.755-3.82 3.17-5.74 8.04-5.74 14.624v52.533h-23.33ZM1322.93 167.525v-20.489h49.98c3.45 0 5.18-1.859 5.18-5.59v-23.284h-3.33c-.99 2.118-2.54 4.222-4.63 6.34-2.1 2.118-4.94 3.847-8.52 5.215-3.58 1.369-8.14 2.046-13.7 2.046-7.16 0-13.43-1.642-18.78-4.942-5.37-3.285-9.54-7.852-12.5-13.688-2.97-5.835-4.44-12.549-4.44-20.113V37.865h23.32v53.282c0 6.959 1.69 12.174 5.1 15.647 3.39 3.472 8.23 5.216 14.53 5.216 7.16 0 12.72-2.392 16.67-7.176 3.95-4.783 5.93-11.454 5.93-20.027V37.865h23.32V146.66c0 6.34-1.85 11.397-5.56 15.187-3.7 3.789-8.64 5.677-14.8 5.677H1322.93ZM1420.63 44.853h7.98v22.822h6.58V44.853h7.98V38.7h-22.54v6.153ZM1465.12 38.7l-4.5 22.794-4.48-22.794h-10.88v28.975h6.43v-20.69l4.09 20.69h9.7l4.09-20.69v20.69h6.43V38.7h-10.88ZM205.864 66.263h-76.401V0h-24.684v71.897c0 7.636 3.021 14.97 8.391 20.373l62.383 62.777 17.454-17.564-46.076-46.365h58.948v-24.84l-.015-.015ZM12.872 30.517l46.075 46.365H0v24.84h76.4v66.264h24.685V96.089c0-7.637-3.021-14.97-8.39-20.374l-62.37-62.762-17.453 17.564Z" />
+ </g>
+ <defs>
+ <clipPath id="a">
+ <path d="M0 0h1476v168H0z" fill="#fff" />
+ </clipPath>
+ </defs>
+ </svg>
+ )
+}
diff --git a/packages/ui/assets/icons.tsx b/packages/ui/assets/icons.tsx
new file mode 100644
index 00000000..5eb38b42
--- /dev/null
+++ b/packages/ui/assets/icons.tsx
@@ -0,0 +1,208 @@
+export const OneDrive = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 256 165"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>OneDrive</title>
+ <path
+ d="m154.66 110.682l52.842-50.534c-10.976-42.8-54.57-68.597-97.37-57.62a80 80 0 0 0-46.952 33.51c.817-.02 91.48 74.644 91.48 74.644"
+ fill="#0364B8"
+ />
+ <path
+ d="m97.618 45.552l-.002.009a63.7 63.7 0 0 0-33.619-9.543c-.274 0-.544.017-.818.02C27.852 36.476-.432 65.47.005 100.798a63.97 63.97 0 0 0 11.493 35.798l79.165-9.915l60.694-48.94z"
+ fill="#0078D4"
+ />
+ <path
+ d="M207.502 60.148a53 53 0 0 0-3.51-.131a51.8 51.8 0 0 0-20.61 4.254l-.002-.005l-32.022 13.475l35.302 43.607l63.11 15.341c13.62-25.283 4.164-56.82-21.12-70.44a52 52 0 0 0-21.148-6.1"
+ fill="#1490DF"
+ />
+ <path
+ d="M11.498 136.596a63.91 63.91 0 0 0 52.5 27.417h139.994a51.99 51.99 0 0 0 45.778-27.323l-98.413-58.95z"
+ fill="#28A8EA"
+ />
+ </svg>
+)
+
+export const GoogleDrive = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 256 229"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Google Drive</title>
+ <path
+ d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z"
+ fill="#0066DA"
+ />
+ <path
+ d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137"
+ fill="#EA4335"
+ />
+ <path
+ d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638"
+ fill="#00832D"
+ />
+ <path
+ d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z"
+ fill="#2684FC"
+ />
+ <path
+ d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z"
+ fill="#00AC47"
+ />
+ <path
+ d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z"
+ fill="#FFBA00"
+ />
+ </svg>
+)
+
+export const Notion = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 256 268"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Notion</title>
+ <path
+ d="M16.092 11.538L164.09.608c18.179-1.56 22.85-.508 34.28 7.801l47.243 33.282C253.406 47.414 256 48.975 256 55.207v182.527c0 11.439-4.155 18.205-18.696 19.24L65.44 267.378c-10.913.517-16.11-1.043-21.825-8.327L8.826 213.814C2.586 205.487 0 199.254 0 191.97V29.726c0-9.352 4.155-17.153 16.092-18.188"
+ fill="#FFF"
+ />
+ <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
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Google Docs</title>
+ <path
+ d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818zm0-3.273H7.091V9.273h9.818zM14.727 6h6l-6-6z"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const GoogleSheets = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Google Sheets</title>
+ <path
+ d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6zm1.363 10.636h-3.41v1.91h3.41zm0 3.273h-3.41v1.91h3.41zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const GoogleSlides = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Google Slides</title>
+ <path
+ d="M16.09 15.273H7.91v-4.637h8.18zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75zm-.363 2.523H6.545v7.363h10.91zm-2.728-5.979V6h6.001l-6-6v3.294z"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const NotionDoc = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Notion Doc</title>
+ <path
+ d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514c-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233l4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const MicrosoftWord = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Microsoft Word</title>
+ <path
+ d="M23.004 1.5q.41 0 .703.293t.293.703v19.008q0 .41-.293.703t-.703.293H6.996q-.41 0-.703-.293T6 21.504V18H.996q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6H6V2.496q0-.41.293-.703t.703-.293zM6.035 11.203l1.442 4.735h1.64l1.57-7.876H9.036l-.937 4.653l-1.325-4.5H5.38l-1.406 4.523l-.938-4.675H1.312l1.57 7.874h1.641zM22.5 21v-3h-15v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3h-15v3Z"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const MicrosoftExcel = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Microsoft Excel</title>
+ <path
+ d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3zM6 13.28l1.42 2.66h2.14l-2.38-3.87l2.34-3.8H7.46l-1.3 2.4l-.05.08l-.04.09l-.64-1.28l-.66-1.29H2.59l2.27 3.82l-2.48 3.85h2.16zM14.25 21v-3H7.5v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3H7.5v3zm8.25 15v-3h-6.75v3zm0-4.5v-3.75h-6.75v3.75zm0-5.25V7.5h-6.75v3.75zm0-5.25V3h-6.75v3Z"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Microsoft PowerPoint</title>
+ <path
+ d="M13.5 1.5q1.453 0 2.795.375t2.508 1.06t2.12 1.641q.956.955 1.641 2.121q.686 1.166 1.061 2.508T24 12t-.375 2.795t-1.06 2.508q-.686 1.166-1.641 2.12q-.955.956-2.121 1.641q-1.166.686-2.508 1.061T13.5 22.5q-1.29 0-2.52-.305q-1.23-.304-2.337-.884T6.58 19.893Q5.625 19.055 4.887 18H.997q-.411 0-.704-.293T0 17.004V6.996q0-.41.293-.703T.996 6h3.89q.739-1.055 1.694-1.893q.955-.837 2.063-1.418q1.107-.58 2.337-.884T13.5 1.5m.75 1.535v8.215h8.215q-.14-1.64-.826-3.076t-1.782-2.531q-1.095-1.096-2.537-1.782t-3.07-.826m-5.262 7.57q0-.68-.228-1.166q-.229-.486-.627-.79q-.399-.305-.938-.446q-.539-.14-1.172-.14H2.848v7.863h1.84v-2.742H5.93q.574 0 1.119-.17t.978-.493q.434-.322.698-.802t.263-1.114M13.5 21q1.172 0 2.262-.287t2.056-.82t1.776-1.278q.808-.744 1.418-1.664t.984-1.986q.375-1.067.469-2.227h-9.703V3.035q-1.735.14-3.27.908T6.797 6h4.207q.41 0 .703.293t.293.703v10.008q0 .41-.293.703t-.703.293H6.797q.644.715 1.412 1.271q.768.557 1.623.944t1.781.586T13.5 21M5.812 9.598q.575 0 .915.228q.34.229.34.838q0 .27-.124.44q-.123.17-.31.275q-.188.105-.422.146t-.445.041H4.687V9.598Z"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const MicrosoftOneNote = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Microsoft OneNote</title>
+ <path
+ d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3ZM4.56 11l2.83 4.93h1.79V8.07H7.44v5.03L4.71 8.07H2.82v7.86h1.74ZM22.5 21v-3h-3v3Zm0-4.5v-3h-3v3Zm0-4.5V9h-3v3Zm0-4.5V3h-15v3H11q.41 0 .7.3q.3.29.3.7v10q0 .41-.3.7q-.29.3-.7.3H7.5v3H18V7.5Z"
+ fill="currentColor"
+ />
+ </svg>
+)
+
+export const PDF = ({ className }: { className?: string }) => (
+ <svg
+ className={className}
+ viewBox="0 0 15 16"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>PDF</title>
+ <path
+ d="M3 13h.86v-.9h.39c.62 0 1.14-.45 1.14-1.06s-.5-1.05-1.14-1.05H3v3Zm.86-1.59v-.72h.3c.2 0 .37.13.37.35s-.16.36-.37.36h-.3ZM6.19 13h1.19c1 0 1.62-.59 1.62-1.52C9 10.61 8.38 10 7.38 10H6.19zm.86-.71V10.7h.29c.33 0 .78.16.78.78c0 .65-.45.81-.78.81zM10 13h.86v-1.07h1.06v-.69h-1.06v-.54h1.21v-.69h-2.06v3Z"
+ fill="currentColor"
+ />
+ <path
+ d="M12.5 16h-10c-.83 0-1.5-.67-1.5-1.5v-13C1 .67 1.67 0 2.5 0h7.09c.4 0 .78.16 1.06.44l2.91 2.91c.28.28.44.66.44 1.06V14.5c0 .83-.67 1.5-1.5 1.5M2.5 1c-.28 0-.5.22-.5.5v13c0 .28.22.5.5.5h10c.28 0 .5-.22.5-.5V4.41a.47.47 0 0 0-.15-.35L9.94 1.15A.5.5 0 0 0 9.59 1z"
+ fill="currentColor"
+ />
+ <path
+ d="M13.38 5h-2.91C9.66 5 9 4.34 9 3.53V.62c0-.28.22-.5.5-.5s.5.22.5.5v2.91c0 .26.21.47.47.47h2.91c.28 0 .5.22.5.5s-.22.5-.5.5"
+ fill="currentColor"
+ />
+ </svg>
+)
diff --git a/packages/ui/biome.json b/packages/ui/biome.json
new file mode 100644
index 00000000..79f38fb5
--- /dev/null
+++ b/packages/ui/biome.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
+ "extends": "//",
+ "linter": {
+ "domains": {
+ "next": "recommended",
+ "react": "recommended"
+ }
+ }
+}
diff --git a/packages/ui/button/external-auth.tsx b/packages/ui/button/external-auth.tsx
new file mode 100644
index 00000000..d61c11e3
--- /dev/null
+++ b/packages/ui/button/external-auth.tsx
@@ -0,0 +1,29 @@
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+
+interface ExternalAuthButtonProps extends React.ComponentProps<typeof Button> {
+ authProvider: string
+ authIcon: React.ReactNode
+}
+
+export function ExternalAuthButton({
+ authProvider,
+ authIcon,
+ className,
+ ...props
+}: ExternalAuthButtonProps) {
+ return (
+ <Button
+ className={cn(
+ "flex flex-grow cursor-pointer max-w-full bg-sm-shark items-center justify-center gap-[0.625rem] rounded-xl border-[1.5px] border-sm-white px-6 py-5 hover:bg-sm-shark-alt",
+ className,
+ )}
+ {...props}
+ >
+ <span className="aspect-square">{authIcon}</span>
+ <span className="text-sm-white text-left text-[0.875rem] tracking-[-0.2px] leading-[1.25rem]">
+ Continue with {authProvider}
+ </span>
+ </Button>
+ )
+}
diff --git a/packages/ui/components.json b/packages/ui/components.json
new file mode 100644
index 00000000..775b19db
--- /dev/null
+++ b/packages/ui/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "./globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@ui/components",
+ "utils": "@lib/utils",
+ "hooks": "@hooks",
+ "lib": "@lib",
+ "ui": "@ui/components"
+ }
+}
diff --git a/packages/ui/components/accordion.tsx b/packages/ui/components/accordion.tsx
new file mode 100644
index 00000000..1a33121e
--- /dev/null
+++ b/packages/ui/components/accordion.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+import type * as React from "react"
+
+function Accordion({
+ ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
+ return (
+ <AccordionPrimitive.Item
+ className={cn("border-b last:border-b-0", className)}
+ data-slot="accordion-item"
+ {...props}
+ />
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
+ return (
+ <AccordionPrimitive.Header className="flex">
+ <AccordionPrimitive.Trigger
+ className={cn(
+ "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
+ className,
+ )}
+ data-slot="accordion-trigger"
+ {...props}
+ >
+ {children}
+ <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
+ </AccordionPrimitive.Trigger>
+ </AccordionPrimitive.Header>
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
+ return (
+ <AccordionPrimitive.Content
+ className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
+ data-slot="accordion-content"
+ {...props}
+ >
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
+ </AccordionPrimitive.Content>
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/packages/ui/components/alert-dialog.tsx b/packages/ui/components/alert-dialog.tsx
new file mode 100644
index 00000000..619f8516
--- /dev/null
+++ b/packages/ui/components/alert-dialog.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+import { buttonVariants } from "@ui/components/button"
+import type * as React from "react"
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
+ return (
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
+ return (
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
+ return (
+ <AlertDialogPrimitive.Overlay
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+ className,
+ )}
+ data-slot="alert-dialog-overlay"
+ {...props}
+ />
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
+ return (
+ <AlertDialogPortal>
+ <AlertDialogOverlay />
+ <AlertDialogPrimitive.Content
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+ className,
+ )}
+ data-slot="alert-dialog-content"
+ {...props}
+ />
+ </AlertDialogPortal>
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+ data-slot="alert-dialog-header"
+ {...props}
+ />
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+ className,
+ )}
+ data-slot="alert-dialog-footer"
+ {...props}
+ />
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
+ return (
+ <AlertDialogPrimitive.Title
+ className={cn("text-lg font-semibold", className)}
+ data-slot="alert-dialog-title"
+ {...props}
+ />
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
+ return (
+ <AlertDialogPrimitive.Description
+ className={cn("text-muted-foreground text-sm", className)}
+ data-slot="alert-dialog-description"
+ {...props}
+ />
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
+ return (
+ <AlertDialogPrimitive.Action
+ className={cn(buttonVariants(), className)}
+ {...props}
+ />
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
+ return (
+ <AlertDialogPrimitive.Cancel
+ className={cn(buttonVariants({ variant: "outline" }), className)}
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/packages/ui/components/avatar.tsx b/packages/ui/components/avatar.tsx
new file mode 100644
index 00000000..d73d2ea6
--- /dev/null
+++ b/packages/ui/components/avatar.tsx
@@ -0,0 +1,52 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+import type * as React from "react"
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
+ return (
+ <AvatarPrimitive.Root
+ className={cn(
+ "relative flex size-8 shrink-0 overflow-hidden rounded-full",
+ className,
+ )}
+ data-slot="avatar"
+ {...props}
+ />
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
+ return (
+ <AvatarPrimitive.Image
+ className={cn("aspect-square size-full", className)}
+ data-slot="avatar-image"
+ {...props}
+ />
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
+ return (
+ <AvatarPrimitive.Fallback
+ className={cn(
+ "bg-muted flex size-full items-center justify-center rounded-full",
+ className,
+ )}
+ data-slot="avatar-fallback"
+ {...props}
+ />
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/packages/ui/components/badge.tsx b/packages/ui/components/badge.tsx
new file mode 100644
index 00000000..977109e6
--- /dev/null
+++ b/packages/ui/components/badge.tsx
@@ -0,0 +1,45 @@
+import { cn } from "@lib/utils"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+import type * as React from "react"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+ <Comp
+ className={cn(badgeVariants({ variant }), className)}
+ data-slot="badge"
+ {...props}
+ />
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/packages/ui/components/breadcrumb.tsx b/packages/ui/components/breadcrumb.tsx
new file mode 100644
index 00000000..7a8104d4
--- /dev/null
+++ b/packages/ui/components/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import { cn } from "@lib/utils"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+import type * as React from "react"
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+ <ol
+ className={cn(
+ "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
+ className,
+ )}
+ data-slot="breadcrumb-list"
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+ <li
+ className={cn("inline-flex items-center gap-1.5", className)}
+ data-slot="breadcrumb-item"
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+ <Comp
+ className={cn("hover:text-foreground transition-colors", className)}
+ data-slot="breadcrumb-link"
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ aria-current="page"
+ aria-disabled="true"
+ className={cn("text-foreground font-normal", className)}
+ data-slot="breadcrumb-page"
+ role="link"
+ tabIndex={0}
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ <li
+ aria-hidden="true"
+ className={cn("[&>svg]:size-3.5", className)}
+ data-slot="breadcrumb-separator"
+ role="presentation"
+ {...props}
+ >
+ {children ?? <ChevronRight />}
+ </li>
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ aria-hidden="true"
+ className={cn("flex size-9 items-center justify-center", className)}
+ data-slot="breadcrumb-ellipsis"
+ role="presentation"
+ {...props}
+ >
+ <MoreHorizontal className="size-4" />
+ <span className="sr-only">More</span>
+ </span>
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/packages/ui/components/button.tsx b/packages/ui/components/button.tsx
new file mode 100644
index 00000000..99d10e10
--- /dev/null
+++ b/packages/ui/components/button.tsx
@@ -0,0 +1,58 @@
+import { cn } from "@lib/utils"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+import type * as React from "react"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps<typeof buttonVariants> & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ <Comp
+ className={cn(buttonVariants({ variant, size, className }))}
+ data-slot="button"
+ {...props}
+ />
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/packages/ui/components/card.tsx b/packages/ui/components/card.tsx
new file mode 100644
index 00000000..cc4b5fd4
--- /dev/null
+++ b/packages/ui/components/card.tsx
@@ -0,0 +1,91 @@
+import { cn } from "@lib/utils"
+import type * as React from "react"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
+ className,
+ )}
+ data-slot="card"
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+ className,
+ )}
+ data-slot="card-header"
+ {...props}
+ />
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("leading-none font-semibold", className)}
+ data-slot="card-title"
+ {...props}
+ />
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("text-muted-foreground text-sm", className)}
+ data-slot="card-description"
+ {...props}
+ />
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+ className,
+ )}
+ data-slot="card-action"
+ {...props}
+ />
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("px-6", className)}
+ data-slot="card-content"
+ {...props}
+ />
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
+ data-slot="card-footer"
+ {...props}
+ />
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/packages/ui/components/carousel.tsx b/packages/ui/components/carousel.tsx
new file mode 100644
index 00000000..ecc89fe1
--- /dev/null
+++ b/packages/ui/components/carousel.tsx
@@ -0,0 +1,238 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+import * as React from "react"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0]
+ api: ReturnType<typeof useEmblaCarousel>[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext<CarouselContextProps | null>(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a <Carousel />")
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext],
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+ <CarouselContext.Provider
+ value={{
+ carouselRef,
+ api: api,
+ opts,
+ orientation:
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
+ scrollPrev,
+ scrollNext,
+ canScrollPrev,
+ canScrollNext,
+ }}
+ >
+ <section
+ className={cn("relative", className)}
+ data-slot="carousel"
+ onKeyDownCapture={handleKeyDown}
+ {...props}
+ >
+ {children}
+ </section>
+ </CarouselContext.Provider>
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+ <div
+ className="overflow-hidden"
+ data-slot="carousel-content"
+ ref={carouselRef}
+ >
+ <div
+ className={cn(
+ "flex",
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
+ className,
+ )}
+ {...props}
+ />
+ </div>
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel()
+
+ return (
+ <div
+ aria-roledescription="slide"
+ className={cn(
+ "min-w-0 shrink-0 grow-0 basis-full",
+ orientation === "horizontal" ? "pl-4" : "pt-4",
+ className,
+ )}
+ data-slot="carousel-item"
+ role="group"
+ {...props}
+ />
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps<typeof Button>) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+ <Button
+ className={cn(
+ "absolute size-8 rounded-full",
+ orientation === "horizontal"
+ ? "top-1/2 -left-12 -translate-y-1/2"
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
+ className,
+ )}
+ data-slot="carousel-previous"
+ disabled={!canScrollPrev}
+ onClick={scrollPrev}
+ size={size}
+ variant={variant}
+ {...props}
+ >
+ <ArrowLeft />
+ <span className="sr-only">Previous slide</span>
+ </Button>
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps<typeof Button>) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+ <Button
+ className={cn(
+ "absolute size-8 rounded-full",
+ orientation === "horizontal"
+ ? "top-1/2 -right-12 -translate-y-1/2"
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
+ className,
+ )}
+ data-slot="carousel-next"
+ disabled={!canScrollNext}
+ onClick={scrollNext}
+ size={size}
+ variant={variant}
+ {...props}
+ >
+ <ArrowRight />
+ <span className="sr-only">Next slide</span>
+ </Button>
+ )
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/packages/ui/components/chart.tsx b/packages/ui/components/chart.tsx
new file mode 100644
index 00000000..6f8f6642
--- /dev/null
+++ b/packages/ui/components/chart.tsx
@@ -0,0 +1,353 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext<ChartContextProps | null>(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a <ChartContainer />")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+ <ChartContext.Provider value={{ config }}>
+ <div
+ className={cn(
+ "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
+ className,
+ )}
+ data-chart={chartId}
+ data-slot="chart"
+ {...props}
+ >
+ <ChartStyle config={config} id={chartId} />
+ <RechartsPrimitive.ResponsiveContainer>
+ {children}
+ </RechartsPrimitive.ResponsiveContainer>
+ </div>
+ </ChartContext.Provider>
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color,
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+ <style
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: shadcn
+ dangerouslySetInnerHTML={{
+ __html: Object.entries(THEMES)
+ .map(
+ ([theme, prefix]) => `
+${prefix} [data-chart=${id}] {
+${colorConfig
+ .map(([key, itemConfig]) => {
+ const color =
+ itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
+ itemConfig.color
+ return color ? ` --color-${key}: ${color};` : null
+ })
+ .join("\n")}
+}
+`,
+ )
+ .join("\n"),
+ }}
+ />
+ )
+}
+
+const ChartTooltip = RechartsPrimitive.Tooltip
+
+function ChartTooltipContent({
+ active,
+ payload,
+ className,
+ indicator = "dot",
+ hideLabel = false,
+ hideIndicator = false,
+ label,
+ labelFormatter,
+ labelClassName,
+ formatter,
+ color,
+ nameKey,
+ labelKey,
+}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
+ React.ComponentProps<"div"> & {
+ hideLabel?: boolean
+ hideIndicator?: boolean
+ indicator?: "line" | "dot" | "dashed"
+ nameKey?: string
+ labelKey?: string
+ }) {
+ const { config } = useChart()
+
+ const tooltipLabel = React.useMemo(() => {
+ if (hideLabel || !payload?.length) {
+ return null
+ }
+
+ const [item] = payload
+ const key = `${labelKey || item?.dataKey || item?.name || "value"}`
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
+ const value =
+ !labelKey && typeof label === "string"
+ ? config[label as keyof typeof config]?.label || label
+ : itemConfig?.label
+
+ if (labelFormatter) {
+ return (
+ <div className={cn("font-medium", labelClassName)}>
+ {labelFormatter(value, payload)}
+ </div>
+ )
+ }
+
+ if (!value) {
+ return null
+ }
+
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>
+ }, [
+ label,
+ labelFormatter,
+ payload,
+ hideLabel,
+ labelClassName,
+ config,
+ labelKey,
+ ])
+
+ if (!active || !payload?.length) {
+ return null
+ }
+
+ const nestLabel = payload.length === 1 && indicator !== "dot"
+
+ return (
+ <div
+ className={cn(
+ "border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
+ className,
+ )}
+ >
+ {!nestLabel ? tooltipLabel : null}
+ <div className="grid gap-1.5">
+ {payload.map((item, index) => {
+ const key = `${nameKey || item.name || item.dataKey || "value"}`
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
+ const indicatorColor = color || item.payload.fill || item.color
+
+ return (
+ <div
+ className={cn(
+ "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
+ indicator === "dot" && "items-center",
+ )}
+ key={item.dataKey}
+ >
+ {formatter && item?.value !== undefined && item.name ? (
+ formatter(item.value, item.name, item, index, item.payload)
+ ) : (
+ <>
+ {itemConfig?.icon ? (
+ <itemConfig.icon />
+ ) : (
+ !hideIndicator && (
+ <div
+ className={cn(
+ "shrink-0 rounded-sm border-(--color-border) bg-(--color-bg)",
+ {
+ "h-2.5 w-2.5": indicator === "dot",
+ "w-1": indicator === "line",
+ "w-0 border-[1.5px] border-dashed bg-transparent":
+ indicator === "dashed",
+ "my-0.5": nestLabel && indicator === "dashed",
+ },
+ )}
+ style={
+ {
+ "--color-bg": indicatorColor,
+ "--color-border": indicatorColor,
+ } as React.CSSProperties
+ }
+ />
+ )
+ )}
+ <div
+ className={cn(
+ "flex flex-1 justify-between leading-none",
+ nestLabel ? "items-end" : "items-center",
+ )}
+ >
+ <div className="grid gap-1.5">
+ {nestLabel ? tooltipLabel : null}
+ <span className="text-muted-foreground">
+ {itemConfig?.label || item.name}
+ </span>
+ </div>
+ {item.value && (
+ <span className="text-foreground font-mono font-medium tabular-nums">
+ {item.value.toLocaleString()}
+ </span>
+ )}
+ </div>
+ </>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ )
+}
+
+const ChartLegend = RechartsPrimitive.Legend
+
+function ChartLegendContent({
+ className,
+ hideIcon = false,
+ payload,
+ verticalAlign = "bottom",
+ nameKey,
+}: React.ComponentProps<"div"> &
+ Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
+ hideIcon?: boolean
+ nameKey?: string
+ }) {
+ const { config } = useChart()
+
+ if (!payload?.length) {
+ return null
+ }
+
+ return (
+ <div
+ className={cn(
+ "flex items-center justify-center gap-4",
+ verticalAlign === "top" ? "pb-3" : "pt-3",
+ className,
+ )}
+ >
+ {payload.map((item) => {
+ const key = `${nameKey || item.dataKey || "value"}`
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
+
+ return (
+ <div
+ className={cn(
+ "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
+ )}
+ key={item.value}
+ >
+ {itemConfig?.icon && !hideIcon ? (
+ <itemConfig.icon />
+ ) : (
+ <div
+ className="h-2 w-2 shrink-0 rounded-sm"
+ style={{
+ backgroundColor: item.color,
+ }}
+ />
+ )}
+ {itemConfig?.label}
+ </div>
+ )
+ })}
+ </div>
+ )
+}
+
+// Helper to extract item config from a payload.
+function getPayloadConfigFromPayload(
+ config: ChartConfig,
+ payload: unknown,
+ key: string,
+) {
+ if (typeof payload !== "object" || payload === null) {
+ return undefined
+ }
+
+ const payloadPayload =
+ "payload" in payload &&
+ typeof payload.payload === "object" &&
+ payload.payload !== null
+ ? payload.payload
+ : undefined
+
+ let configLabelKey: string = key
+
+ if (
+ key in payload &&
+ typeof payload[key as keyof typeof payload] === "string"
+ ) {
+ configLabelKey = payload[key as keyof typeof payload] as string
+ } else if (
+ payloadPayload &&
+ key in payloadPayload &&
+ typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
+ ) {
+ configLabelKey = payloadPayload[
+ key as keyof typeof payloadPayload
+ ] as string
+ }
+
+ return configLabelKey in config
+ ? config[configLabelKey]
+ : config[key as keyof typeof config]
+}
+
+export {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ ChartStyle,
+}
diff --git a/packages/ui/components/checkbox.tsx b/packages/ui/components/checkbox.tsx
new file mode 100644
index 00000000..45a968f9
--- /dev/null
+++ b/packages/ui/components/checkbox.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+import type * as React from "react"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+ return (
+ <CheckboxPrimitive.Root
+ className={cn(
+ "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded border shadow-xs transition-shadow outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ data-slot="checkbox"
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ className="flex items-center justify-center text-current transition-none"
+ data-slot="checkbox-indicator"
+ >
+ <CheckIcon className="size-3.5" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+ )
+}
+
+export { Checkbox }
diff --git a/packages/ui/components/collapsible.tsx b/packages/ui/components/collapsible.tsx
new file mode 100644
index 00000000..f8de4e4c
--- /dev/null
+++ b/packages/ui/components/collapsible.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
+ return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
+ return (
+ <CollapsiblePrimitive.CollapsibleTrigger
+ data-slot="collapsible-trigger"
+ {...props}
+ />
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
+ return (
+ <CollapsiblePrimitive.CollapsibleContent
+ data-slot="collapsible-content"
+ {...props}
+ />
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/packages/ui/components/combobox.tsx b/packages/ui/components/combobox.tsx
new file mode 100644
index 00000000..8c9f9c97
--- /dev/null
+++ b/packages/ui/components/combobox.tsx
@@ -0,0 +1,161 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@ui/components/command"
+import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
+import { Check, ChevronsUpDown, X } from "lucide-react"
+import * as React from "react"
+
+interface Option {
+ value: string
+ label: string
+}
+
+interface ComboboxProps {
+ options: Option[]
+ onSelect: (value: string) => void
+ onSubmit: (newName: string) => void
+ selectedValues: string[]
+ setSelectedValues: React.Dispatch<React.SetStateAction<string[]>>
+ className?: string
+ placeholder?: string
+ triggerClassName?: string
+}
+
+export function Combobox({
+ options,
+ onSelect,
+ onSubmit,
+ selectedValues,
+ setSelectedValues,
+ className,
+ placeholder = "Select...",
+ triggerClassName,
+}: ComboboxProps) {
+ const [open, setOpen] = React.useState(false)
+ const [inputValue, setInputValue] = React.useState("")
+
+ const handleSelect = (value: string) => {
+ onSelect(value)
+ setOpen(false)
+ setInputValue("")
+ }
+
+ const handleCreate = () => {
+ if (inputValue.trim()) {
+ onSubmit(inputValue)
+ setOpen(false)
+ setInputValue("")
+ }
+ }
+
+ const handleRemove = (valueToRemove: string) => {
+ setSelectedValues((prev) => prev.filter((value) => value !== valueToRemove))
+ }
+
+ const filteredOptions = options.filter(
+ (option) => !selectedValues.includes(option.value),
+ )
+
+ const isNewValue =
+ inputValue.trim() &&
+ !options.some(
+ (option) => option.label.toLowerCase() === inputValue.toLowerCase(),
+ )
+
+ return (
+ <Popover onOpenChange={setOpen} open={open}>
+ <PopoverTrigger asChild>
+ {/** biome-ignore lint/a11y/useSemanticElements: shadcn*/}
+ <Button
+ aria-expanded={open}
+ className={cn(
+ "w-full justify-between min-h-10 h-auto",
+ triggerClassName,
+ )}
+ role="combobox"
+ variant="outline"
+ >
+ <div className="flex flex-wrap gap-1 items-center w-full">
+ {selectedValues.length > 0 ? (
+ selectedValues.map((value) => {
+ const option = options.find((opt) => opt.value === value)
+ return (
+ <span
+ className="inline-flex items-center gap-1 px-2 py-0.5 bg-secondary text-sm rounded-md"
+ key={value}
+ >
+ {option?.label || value}
+ <button
+ className="hover:text-destructive"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleRemove(value)
+ }}
+ type="button"
+ >
+ <X className="h-3 w-3" />
+ </button>
+ </span>
+ )
+ })
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ </div>
+ <ChevronsUpDown className="opacity-50 ml-2 shrink-0" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className={cn("w-full p-0", className)}>
+ <Command>
+ <CommandInput
+ className="h-9"
+ onValueChange={setInputValue}
+ placeholder="Search or create..."
+ value={inputValue}
+ />
+ <CommandList>
+ {filteredOptions.length === 0 && !isNewValue && (
+ <CommandEmpty>No options found.</CommandEmpty>
+ )}
+ <CommandGroup>
+ {filteredOptions.map((option) => (
+ <CommandItem
+ key={option.value}
+ onSelect={() => handleSelect(option.value)}
+ value={option.value}
+ >
+ {option.label}
+ <Check
+ className={cn(
+ "ml-auto",
+ selectedValues.includes(option.value)
+ ? "opacity-100"
+ : "opacity-0",
+ )}
+ />
+ </CommandItem>
+ ))}
+ {isNewValue && (
+ <CommandItem
+ className="text-primary cursor-pointer"
+ onSelect={handleCreate}
+ >
+ Create "{inputValue}"
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+}
diff --git a/packages/ui/components/command.tsx b/packages/ui/components/command.tsx
new file mode 100644
index 00000000..4c04c8c3
--- /dev/null
+++ b/packages/ui/components/command.tsx
@@ -0,0 +1,183 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@ui/components/dialog"
+import { Command as CommandPrimitive } from "cmdk"
+import { SearchIcon } from "lucide-react"
+import type * as React from "react"
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps<typeof CommandPrimitive>) {
+ return (
+ <CommandPrimitive
+ className={cn(
+ "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
+ className,
+ )}
+ data-slot="command"
+ {...props}
+ />
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps<typeof Dialog> & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+}) {
+ return (
+ <Dialog {...props}>
+ <DialogHeader className="sr-only">
+ <DialogTitle>{title}</DialogTitle>
+ <DialogDescription>{description}</DialogDescription>
+ </DialogHeader>
+ <DialogContent
+ className={cn("overflow-hidden p-0", className)}
+ showCloseButton={showCloseButton}
+ >
+ <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+ {children}
+ </Command>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps<typeof CommandPrimitive.Input>) {
+ return (
+ <div
+ className="flex h-9 items-center gap-2 border-b px-3"
+ data-slot="command-input-wrapper"
+ >
+ <SearchIcon className="size-4 shrink-0 opacity-50" />
+ <CommandPrimitive.Input
+ className={cn(
+ "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ data-slot="command-input"
+ {...props}
+ />
+ </div>
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps<typeof CommandPrimitive.List>) {
+ return (
+ <CommandPrimitive.List
+ className={cn(
+ "max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto",
+ className,
+ )}
+ data-slot="command-list"
+ {...props}
+ />
+ )
+}
+
+function CommandEmpty({
+ ...props
+}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
+ return (
+ <CommandPrimitive.Empty
+ className="py-6 text-center text-sm"
+ data-slot="command-empty"
+ {...props}
+ />
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps<typeof CommandPrimitive.Group>) {
+ return (
+ <CommandPrimitive.Group
+ className={cn(
+ "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
+ className,
+ )}
+ data-slot="command-group"
+ {...props}
+ />
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
+ return (
+ <CommandPrimitive.Separator
+ className={cn("bg-border -mx-1 h-px", className)}
+ data-slot="command-separator"
+ {...props}
+ />
+ )
+}
+
+function CommandItem({
+ className,
+ ...props
+}: React.ComponentProps<typeof CommandPrimitive.Item>) {
+ return (
+ <CommandPrimitive.Item
+ className={cn(
+ "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className,
+ )}
+ data-slot="command-item"
+ {...props}
+ />
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ className={cn(
+ "text-muted-foreground ml-auto text-xs tracking-widest",
+ className,
+ )}
+ data-slot="command-shortcut"
+ {...props}
+ />
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/packages/ui/components/dialog.tsx b/packages/ui/components/dialog.tsx
new file mode 100644
index 00000000..b0a45b55
--- /dev/null
+++ b/packages/ui/components/dialog.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+import type * as React from "react"
+
+function Dialog({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+ return (
+ <DialogPrimitive.Overlay
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+ className,
+ )}
+ data-slot="dialog-overlay"
+ {...props}
+ />
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Content> & {
+ showCloseButton?: boolean
+}) {
+ return (
+ <DialogPortal data-slot="dialog-portal">
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+ className,
+ )}
+ data-slot="dialog-content"
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close
+ className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
+ data-slot="dialog-close"
+ >
+ <XIcon />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ )}
+ </DialogPrimitive.Content>
+ </DialogPortal>
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+ data-slot="dialog-header"
+ {...props}
+ />
+ )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+ className,
+ )}
+ data-slot="dialog-footer"
+ {...props}
+ />
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+ return (
+ <DialogPrimitive.Title
+ className={cn("text-lg leading-none font-semibold", className)}
+ data-slot="dialog-title"
+ {...props}
+ />
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+ return (
+ <DialogPrimitive.Description
+ className={cn("text-muted-foreground text-sm", className)}
+ data-slot="dialog-description"
+ {...props}
+ />
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/packages/ui/components/drawer.tsx b/packages/ui/components/drawer.tsx
new file mode 100644
index 00000000..6431e856
--- /dev/null
+++ b/packages/ui/components/drawer.tsx
@@ -0,0 +1,134 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import type * as React from "react"
+import { Drawer as DrawerPrimitive } from "vaul"
+
+function Drawer({
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
+ return <DrawerPrimitive.Root data-slot="drawer" {...props} />
+}
+
+function DrawerTrigger({
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
+ return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
+}
+
+function DrawerPortal({
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
+ return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
+}
+
+function DrawerClose({
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
+ return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
+}
+
+function DrawerOverlay({
+ className,
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
+ return (
+ <DrawerPrimitive.Overlay
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+ className,
+ )}
+ data-slot="drawer-overlay"
+ {...props}
+ />
+ )
+}
+
+function DrawerContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
+ return (
+ <DrawerPortal data-slot="drawer-portal">
+ <DrawerOverlay />
+ <DrawerPrimitive.Content
+ className={cn(
+ "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
+ "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
+ "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
+ "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
+ "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
+ className,
+ )}
+ data-slot="drawer-content"
+ {...props}
+ >
+ <div className="bg-muted mx-auto mt-4 hidden h-2 w-24 shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
+ {children}
+ </DrawerPrimitive.Content>
+ </DrawerPortal>
+ )
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
+ className,
+ )}
+ data-slot="drawer-header"
+ {...props}
+ />
+ )
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+ data-slot="drawer-footer"
+ {...props}
+ />
+ )
+}
+
+function DrawerTitle({
+ className,
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
+ return (
+ <DrawerPrimitive.Title
+ className={cn("text-foreground font-semibold", className)}
+ data-slot="drawer-title"
+ {...props}
+ />
+ )
+}
+
+function DrawerDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
+ return (
+ <DrawerPrimitive.Description
+ className={cn("text-muted-foreground text-sm", className)}
+ data-slot="drawer-description"
+ {...props}
+ />
+ )
+}
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+}
diff --git a/packages/ui/components/dropdown-menu.tsx b/packages/ui/components/dropdown-menu.tsx
new file mode 100644
index 00000000..fbd09e9c
--- /dev/null
+++ b/packages/ui/components/dropdown-menu.tsx
@@ -0,0 +1,256 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
+import type * as React from "react"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+ return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
+ return (
+ <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
+ return (
+ <DropdownMenuPrimitive.Trigger
+ data-slot="dropdown-menu-trigger"
+ {...props}
+ />
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
+ return (
+ <DropdownMenuPrimitive.Portal>
+ <DropdownMenuPrimitive.Content
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
+ className,
+ )}
+ data-slot="dropdown-menu-content"
+ sideOffset={sideOffset}
+ {...props}
+ />
+ </DropdownMenuPrimitive.Portal>
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
+ return (
+ <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+ <DropdownMenuPrimitive.Item
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className,
+ )}
+ data-inset={inset}
+ data-slot="dropdown-menu-item"
+ data-variant={variant}
+ {...props}
+ />
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
+ return (
+ <DropdownMenuPrimitive.CheckboxItem
+ checked={checked}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className,
+ )}
+ data-slot="dropdown-menu-checkbox-item"
+ {...props}
+ >
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <CheckIcon className="size-4" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.CheckboxItem>
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
+ return (
+ <DropdownMenuPrimitive.RadioGroup
+ data-slot="dropdown-menu-radio-group"
+ {...props}
+ />
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
+ return (
+ <DropdownMenuPrimitive.RadioItem
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className,
+ )}
+ data-slot="dropdown-menu-radio-item"
+ {...props}
+ >
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <CircleIcon className="size-2 fill-current" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.RadioItem>
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
+ inset?: boolean
+}) {
+ return (
+ <DropdownMenuPrimitive.Label
+ className={cn(
+ "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
+ className,
+ )}
+ data-inset={inset}
+ data-slot="dropdown-menu-label"
+ {...props}
+ />
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
+ return (
+ <DropdownMenuPrimitive.Separator
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
+ data-slot="dropdown-menu-separator"
+ {...props}
+ />
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ className={cn(
+ "text-muted-foreground ml-auto text-xs tracking-widest",
+ className,
+ )}
+ data-slot="dropdown-menu-shortcut"
+ {...props}
+ />
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
+ return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
+ inset?: boolean
+}) {
+ return (
+ <DropdownMenuPrimitive.SubTrigger
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
+ className,
+ )}
+ data-inset={inset}
+ data-slot="dropdown-menu-sub-trigger"
+ {...props}
+ >
+ {children}
+ <ChevronRightIcon className="ml-auto size-4" />
+ </DropdownMenuPrimitive.SubTrigger>
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
+ return (
+ <DropdownMenuPrimitive.SubContent
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
+ className,
+ )}
+ data-slot="dropdown-menu-sub-content"
+ {...props}
+ />
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/packages/ui/components/input.tsx b/packages/ui/components/input.tsx
new file mode 100644
index 00000000..cb670693
--- /dev/null
+++ b/packages/ui/components/input.tsx
@@ -0,0 +1,20 @@
+import { cn } from "@lib/utils"
+import type * as React from "react"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+ <input
+ className={cn(
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2",
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ className,
+ )}
+ data-slot="input"
+ type={type}
+ {...props}
+ />
+ )
+}
+
+export { Input }
diff --git a/packages/ui/components/label.tsx b/packages/ui/components/label.tsx
new file mode 100644
index 00000000..f1c2a2f4
--- /dev/null
+++ b/packages/ui/components/label.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import type * as React from "react"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+ return (
+ <LabelPrimitive.Root
+ className={cn(
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+ className,
+ )}
+ data-slot="label"
+ {...props}
+ />
+ )
+}
+
+export { Label }
diff --git a/packages/ui/components/popover.tsx b/packages/ui/components/popover.tsx
new file mode 100644
index 00000000..bbb8885a
--- /dev/null
+++ b/packages/ui/components/popover.tsx
@@ -0,0 +1,47 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+import type * as React from "react"
+
+function Popover({
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+ return (
+ <PopoverPrimitive.Portal>
+ <PopoverPrimitive.Content
+ align={align}
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+ className,
+ )}
+ data-slot="popover-content"
+ sideOffset={sideOffset}
+ {...props}
+ />
+ </PopoverPrimitive.Portal>
+ )
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+ return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/packages/ui/components/progress.tsx b/packages/ui/components/progress.tsx
new file mode 100644
index 00000000..c42d1c0c
--- /dev/null
+++ b/packages/ui/components/progress.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+import type * as React from "react"
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
+ return (
+ <ProgressPrimitive.Root
+ className={cn(
+ "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
+ className,
+ )}
+ data-slot="progress"
+ {...props}
+ >
+ <ProgressPrimitive.Indicator
+ className="bg-primary h-full w-full flex-1 transition-all"
+ data-slot="progress-indicator"
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+ />
+ </ProgressPrimitive.Root>
+ )
+}
+
+export { Progress }
diff --git a/packages/ui/components/scroll-area.tsx b/packages/ui/components/scroll-area.tsx
new file mode 100644
index 00000000..26ce58ca
--- /dev/null
+++ b/packages/ui/components/scroll-area.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+import type * as React from "react"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+ return (
+ <ScrollAreaPrimitive.Root
+ className={cn("relative overflow-hidden", className)}
+ data-slot="scroll-area"
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport
+ className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-2 focus-visible:outline-1"
+ data-slot="scroll-area-viewport"
+ >
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+ return (
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
+ className={cn(
+ "flex touch-none p-px transition-colors select-none",
+ orientation === "vertical" &&
+ "h-full w-2.5 border-l border-l-transparent",
+ orientation === "horizontal" &&
+ "h-2.5 flex-col border-t border-t-transparent",
+ className,
+ )}
+ data-slot="scroll-area-scrollbar"
+ orientation={orientation}
+ {...props}
+ >
+ <ScrollAreaPrimitive.ScrollAreaThumb
+ className="bg-border relative flex-1 rounded-full"
+ data-slot="scroll-area-thumb"
+ />
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/packages/ui/components/select.tsx b/packages/ui/components/select.tsx
new file mode 100644
index 00000000..ff905ed0
--- /dev/null
+++ b/packages/ui/components/select.tsx
@@ -0,0 +1,184 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import type * as React from "react"
+
+function Select({
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+ return <SelectPrimitive.Root data-slot="select" {...props} />
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
+ size?: "sm" | "default"
+}) {
+ return (
+ <SelectPrimitive.Trigger
+ className={cn(
+ "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className,
+ )}
+ data-size={size}
+ data-slot="select-trigger"
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon asChild>
+ <ChevronDownIcon className="size-4 opacity-50" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+ return (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
+ position === "popper" &&
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+ className,
+ )}
+ data-slot="select-content"
+ position={position}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ "p-1",
+ position === "popper" &&
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+ return (
+ <SelectPrimitive.Label
+ className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+ data-slot="select-label"
+ {...props}
+ />
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+ return (
+ <SelectPrimitive.Item
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+ className,
+ )}
+ data-slot="select-item"
+ {...props}
+ >
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
+ <SelectPrimitive.ItemIndicator>
+ <CheckIcon className="size-4" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+ return (
+ <SelectPrimitive.Separator
+ className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+ data-slot="select-separator"
+ {...props}
+ />
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
+ return (
+ <SelectPrimitive.ScrollUpButton
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ data-slot="select-scroll-up-button"
+ {...props}
+ >
+ <ChevronUpIcon className="size-4" />
+ </SelectPrimitive.ScrollUpButton>
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+ return (
+ <SelectPrimitive.ScrollDownButton
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ data-slot="select-scroll-down-button"
+ {...props}
+ >
+ <ChevronDownIcon className="size-4" />
+ </SelectPrimitive.ScrollDownButton>
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/packages/ui/components/separator.tsx b/packages/ui/components/separator.tsx
new file mode 100644
index 00000000..670b6944
--- /dev/null
+++ b/packages/ui/components/separator.tsx
@@ -0,0 +1,27 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+import type * as React from "react"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+ return (
+ <SeparatorPrimitive.Root
+ className={cn(
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+ className,
+ )}
+ data-slot="separator"
+ decorative={decorative}
+ orientation={orientation}
+ {...props}
+ />
+ )
+}
+
+export { Separator }
diff --git a/packages/ui/components/shadcn-io/dropzone.tsx b/packages/ui/components/shadcn-io/dropzone.tsx
new file mode 100644
index 00000000..dc54c3d1
--- /dev/null
+++ b/packages/ui/components/shadcn-io/dropzone.tsx
@@ -0,0 +1,202 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import { UploadIcon } from "lucide-react"
+import type { ReactNode } from "react"
+import { createContext, useContext } from "react"
+import type { DropEvent, DropzoneOptions, FileRejection } from "react-dropzone"
+import { useDropzone } from "react-dropzone"
+
+type DropzoneContextType = {
+ src?: File[]
+ accept?: DropzoneOptions["accept"]
+ maxSize?: DropzoneOptions["maxSize"]
+ minSize?: DropzoneOptions["minSize"]
+ maxFiles?: DropzoneOptions["maxFiles"]
+}
+
+const renderBytes = (bytes: number) => {
+ const units = ["B", "KB", "MB", "GB", "TB", "PB"]
+ let size = bytes
+ let unitIndex = 0
+
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024
+ unitIndex++
+ }
+
+ return `${size.toFixed(2)}${units[unitIndex]}`
+}
+
+const DropzoneContext = createContext<DropzoneContextType | undefined>(
+ undefined,
+)
+
+export type DropzoneProps = Omit<DropzoneOptions, "onDrop"> & {
+ src?: File[]
+ className?: string
+ onDrop?: (
+ acceptedFiles: File[],
+ fileRejections: FileRejection[],
+ event: DropEvent,
+ ) => void
+ children?: ReactNode
+}
+
+export const Dropzone = ({
+ accept,
+ maxFiles = 1,
+ maxSize,
+ minSize,
+ onDrop,
+ onError,
+ disabled,
+ src,
+ className,
+ children,
+ ...props
+}: DropzoneProps) => {
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ accept,
+ maxFiles,
+ maxSize,
+ minSize,
+ onError,
+ disabled,
+ onDrop: (acceptedFiles, fileRejections, event) => {
+ if (fileRejections.length > 0) {
+ const message = fileRejections.at(0)?.errors.at(0)?.message
+ onError?.(new Error(message))
+ return
+ }
+
+ onDrop?.(acceptedFiles, fileRejections, event)
+ },
+ ...props,
+ })
+
+ return (
+ <DropzoneContext.Provider
+ key={JSON.stringify(src)}
+ value={{ src, accept, maxSize, minSize, maxFiles }}
+ >
+ <Button
+ className={cn(
+ "relative h-auto w-full flex-col overflow-hidden p-8",
+ isDragActive && "outline-none ring-1 ring-ring",
+ className,
+ )}
+ disabled={disabled}
+ type="button"
+ variant="outline"
+ {...getRootProps()}
+ >
+ <input {...getInputProps()} disabled={disabled} />
+ {children}
+ </Button>
+ </DropzoneContext.Provider>
+ )
+}
+
+const useDropzoneContext = () => {
+ const context = useContext(DropzoneContext)
+
+ if (!context) {
+ throw new Error("useDropzoneContext must be used within a Dropzone")
+ }
+
+ return context
+}
+
+export type DropzoneContentProps = {
+ children?: ReactNode
+ className?: string
+}
+
+const maxLabelItems = 1
+
+export const DropzoneContent = ({
+ children,
+ className,
+}: DropzoneContentProps) => {
+ const { src } = useDropzoneContext()
+
+ if (!src) {
+ return null
+ }
+
+ if (children) {
+ return children
+ }
+
+ return (
+ <div className={cn("flex flex-col items-center justify-center", className)}>
+ <div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
+ <UploadIcon size={16} />
+ </div>
+ <p className="my-2 w-full truncate font-medium text-sm">
+ {src.length > maxLabelItems
+ ? `${new Intl.ListFormat("en").format(
+ src.slice(0, maxLabelItems).map((file) => file.name),
+ )} and ${src.length - maxLabelItems} more`
+ : new Intl.ListFormat("en").format(src.map((file) => file.name))}
+ </p>
+ <p className="w-full text-wrap text-muted-foreground text-xs">
+ Drag and drop or click to replace
+ </p>
+ </div>
+ )
+}
+
+export type DropzoneEmptyStateProps = {
+ children?: ReactNode
+ className?: string
+}
+
+export const DropzoneEmptyState = ({
+ children,
+ className,
+}: DropzoneEmptyStateProps) => {
+ const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext()
+
+ if (src) {
+ return null
+ }
+
+ if (children) {
+ return children
+ }
+
+ let caption = ""
+
+ if (accept) {
+ caption += "Accepts "
+ caption += new Intl.ListFormat("en").format(Object.keys(accept))
+ }
+
+ if (minSize && maxSize) {
+ caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`
+ } else if (minSize) {
+ caption += ` at least ${renderBytes(minSize)}`
+ } else if (maxSize) {
+ caption += ` less than ${renderBytes(maxSize)}`
+ }
+
+ return (
+ <div className={cn("flex flex-col items-center justify-center", className)}>
+ <div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
+ <UploadIcon size={16} />
+ </div>
+ <p className="my-2 w-full truncate text-wrap font-medium text-sm">
+ Upload {maxFiles === 1 ? "a file" : "files"}
+ </p>
+ <p className="w-full truncate text-wrap text-muted-foreground text-xs">
+ Drag and drop or click to upload
+ </p>
+ {caption && (
+ <p className="text-wrap text-muted-foreground text-xs">{caption}.</p>
+ )}
+ </div>
+ )
+}
diff --git a/packages/ui/components/sheet.tsx b/packages/ui/components/sheet.tsx
new file mode 100644
index 00000000..e5f49634
--- /dev/null
+++ b/packages/ui/components/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+import type * as React from "react"
+
+function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
+ return <SheetPrimitive.Root data-slot="sheet" {...props} />
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Close>) {
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
+ return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
+ return (
+ <SheetPrimitive.Overlay
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+ className,
+ )}
+ data-slot="sheet-overlay"
+ {...props}
+ />
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Content> & {
+ side?: "top" | "right" | "bottom" | "left"
+}) {
+ return (
+ <SheetPortal>
+ <SheetOverlay />
+ <SheetPrimitive.Content
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+ side === "right" &&
+ "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
+ side === "left" &&
+ "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
+ side === "top" &&
+ "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
+ side === "bottom" &&
+ "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
+ className,
+ )}
+ data-slot="sheet-content"
+ {...props}
+ >
+ {children}
+ <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
+ <XIcon className="size-4" />
+ <span className="sr-only">Close</span>
+ </SheetPrimitive.Close>
+ </SheetPrimitive.Content>
+ </SheetPortal>
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("flex flex-col gap-1.5 p-4", className)}
+ data-slot="sheet-header"
+ {...props}
+ />
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+ data-slot="sheet-footer"
+ {...props}
+ />
+ )
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Title>) {
+ return (
+ <SheetPrimitive.Title
+ className={cn("text-foreground font-semibold", className)}
+ data-slot="sheet-title"
+ {...props}
+ />
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Description>) {
+ return (
+ <SheetPrimitive.Description
+ className={cn("text-muted-foreground text-sm", className)}
+ data-slot="sheet-description"
+ {...props}
+ />
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/packages/ui/components/sidebar.tsx b/packages/ui/components/sidebar.tsx
new file mode 100644
index 00000000..626e23d0
--- /dev/null
+++ b/packages/ui/components/sidebar.tsx
@@ -0,0 +1,725 @@
+"use client"
+
+import { useIsMobile } from "@hooks/use-mobile"
+import { cn } from "@lib/utils"
+import { Slot } from "@radix-ui/react-slot"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import { Separator } from "@ui/components/separator"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@ui/components/sheet"
+import { Skeleton } from "@ui/components/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@ui/components/tooltip"
+import { cva, type VariantProps } from "class-variance-authority"
+import { PanelLeftIcon } from "lucide-react"
+import * as React from "react"
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state"
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH_MOBILE = "18rem"
+const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = React.createContext<SidebarContextProps | null>(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}) {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = React.useState(false)
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen)
+ const open = openProp ?? _open
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
+ },
+ [setOpenProp, open],
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
+ }, [isMobile, setOpen])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const contextValue = React.useMemo<SidebarContextProps>(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, toggleSidebar],
+ )
+
+ return (
+ <SidebarContext.Provider value={contextValue}>
+ <TooltipProvider delayDuration={0}>
+ <div
+ className={cn(
+ "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
+ className,
+ )}
+ data-slot="sidebar-wrapper"
+ style={
+ {
+ "--sidebar-width": SIDEBAR_WIDTH,
+ "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
+ ...style,
+ } as React.CSSProperties
+ }
+ {...props}
+ >
+ {children}
+ </div>
+ </TooltipProvider>
+ </SidebarContext.Provider>
+ )
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+ <div
+ className={cn(
+ "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
+ className,
+ )}
+ data-slot="sidebar"
+ {...props}
+ >
+ {children}
+ </div>
+ )
+ }
+
+ if (isMobile) {
+ return (
+ <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
+ <SheetContent
+ className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
+ data-mobile="true"
+ data-sidebar="sidebar"
+ data-slot="sidebar"
+ side={side}
+ style={
+ {
+ "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
+ } as React.CSSProperties
+ }
+ >
+ <SheetHeader className="sr-only">
+ <SheetTitle>Sidebar</SheetTitle>
+ <SheetDescription>Displays the mobile sidebar.</SheetDescription>
+ </SheetHeader>
+ <div className="flex h-full w-full flex-col">{children}</div>
+ </SheetContent>
+ </Sheet>
+ )
+ }
+
+ return (
+ <div
+ className="group peer text-sidebar-foreground hidden md:block"
+ data-collapsible={state === "collapsed" ? collapsible : ""}
+ data-side={side}
+ data-slot="sidebar"
+ data-state={state}
+ data-variant={variant}
+ >
+ {/* This is what handles the sidebar gap on desktop */}
+ <div
+ className={cn(
+ "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
+ "group-data-[collapsible=offcanvas]:w-0",
+ "group-data-[side=right]:rotate-180",
+ variant === "floating" || variant === "inset"
+ ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
+ )}
+ data-slot="sidebar-gap"
+ />
+ <div
+ className={cn(
+ "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
+ side === "left"
+ ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
+ : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
+ // Adjust the padding for floating and inset variants.
+ variant === "floating" || variant === "inset"
+ ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
+ className,
+ )}
+ data-slot="sidebar-container"
+ {...props}
+ >
+ <div
+ className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
+ data-sidebar="sidebar"
+ data-slot="sidebar-inner"
+ >
+ {children}
+ </div>
+ </div>
+ </div>
+ )
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps<typeof Button>) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+ <Button
+ className={cn("size-7", className)}
+ data-sidebar="trigger"
+ data-slot="sidebar-trigger"
+ onClick={(event) => {
+ onClick?.(event)
+ toggleSidebar()
+ }}
+ size="icon"
+ variant="ghost"
+ {...props}
+ >
+ <PanelLeftIcon />
+ <span className="sr-only">Toggle Sidebar</span>
+ </Button>
+ )
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+ <button
+ aria-label="Toggle Sidebar"
+ className={cn(
+ "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
+ "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
+ "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
+ "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
+ "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
+ "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
+ className,
+ )}
+ data-sidebar="rail"
+ data-slot="sidebar-rail"
+ onClick={toggleSidebar}
+ tabIndex={-1}
+ title="Toggle Sidebar"
+ {...props}
+ />
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+ <main
+ className={cn(
+ "bg-background relative flex w-full flex-1 flex-col",
+ "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
+ className,
+ )}
+ data-slot="sidebar-inset"
+ {...props}
+ />
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps<typeof Input>) {
+ return (
+ <Input
+ className={cn("bg-background h-8 w-full shadow-none", className)}
+ data-sidebar="input"
+ data-slot="sidebar-input"
+ {...props}
+ />
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("flex flex-col gap-2 p-2", className)}
+ data-sidebar="header"
+ data-slot="sidebar-header"
+ {...props}
+ />
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("flex flex-col gap-2 p-2", className)}
+ data-sidebar="footer"
+ data-slot="sidebar-footer"
+ {...props}
+ />
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps<typeof Separator>) {
+ return (
+ <Separator
+ className={cn("bg-sidebar-border mx-2 w-auto", className)}
+ data-sidebar="separator"
+ data-slot="sidebar-separator"
+ {...props}
+ />
+ )
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
+ className,
+ )}
+ data-sidebar="content"
+ data-slot="sidebar-content"
+ {...props}
+ />
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
+ data-sidebar="group"
+ data-slot="sidebar-group"
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div"
+
+ return (
+ <Comp
+ className={cn(
+ "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ className,
+ )}
+ data-sidebar="group-label"
+ data-slot="sidebar-group-label"
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ <Comp
+ className={cn(
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ data-sidebar="group-action"
+ data-slot="sidebar-group-action"
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("w-full text-sm", className)}
+ data-sidebar="group-content"
+ data-slot="sidebar-group-content"
+ {...props}
+ />
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+ <ul
+ className={cn("flex w-full min-w-0 flex-col gap-1", className)}
+ data-sidebar="menu"
+ data-slot="sidebar-menu"
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+ <li
+ className={cn("group/menu-item relative", className)}
+ data-sidebar="menu-item"
+ data-slot="sidebar-menu-item"
+ {...props}
+ />
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+)
+
+function SidebarMenuButton({
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps<typeof TooltipContent>
+} & VariantProps<typeof sidebarMenuButtonVariants>) {
+ const Comp = asChild ? Slot : "button"
+ const { isMobile, state } = useSidebar()
+
+ const button = (
+ <Comp
+ className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
+ data-active={isActive}
+ data-sidebar="menu-button"
+ data-size={size}
+ data-slot="sidebar-menu-button"
+ {...props}
+ />
+ )
+
+ if (!tooltip) {
+ return button
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent
+ align="center"
+ hidden={state !== "collapsed" || isMobile}
+ side="right"
+ {...tooltip}
+ />
+ </Tooltip>
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean
+ showOnHover?: boolean
+}) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ <Comp
+ className={cn(
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
+ className,
+ )}
+ data-sidebar="menu-action"
+ data-slot="sidebar-menu-action"
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn(
+ "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
+ "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ data-sidebar="menu-badge"
+ data-slot="sidebar-menu-badge"
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const width = React.useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ }, [])
+
+ return (
+ <div
+ className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
+ data-sidebar="menu-skeleton"
+ data-slot="sidebar-menu-skeleton"
+ {...props}
+ >
+ {showIcon && (
+ <Skeleton
+ className="size-4 rounded-md"
+ data-sidebar="menu-skeleton-icon"
+ />
+ )}
+ <Skeleton
+ className="h-4 max-w-(--skeleton-width) flex-1"
+ data-sidebar="menu-skeleton-text"
+ style={
+ {
+ "--skeleton-width": width,
+ } as React.CSSProperties
+ }
+ />
+ </div>
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+ <ul
+ className={cn(
+ "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ data-sidebar="menu-sub"
+ data-slot="sidebar-menu-sub"
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ <li
+ className={cn("group/menu-sub-item relative", className)}
+ data-sidebar="menu-sub-item"
+ data-slot="sidebar-menu-sub-item"
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuSubButton({
+ asChild = false,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+ size?: "sm" | "md"
+ isActive?: boolean
+}) {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+ <Comp
+ className={cn(
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ data-active={isActive}
+ data-sidebar="menu-sub-button"
+ data-size={size}
+ data-slot="sidebar-menu-sub-button"
+ {...props}
+ />
+ )
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/packages/ui/components/skeleton.tsx b/packages/ui/components/skeleton.tsx
new file mode 100644
index 00000000..980e9e8a
--- /dev/null
+++ b/packages/ui/components/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("bg-accent animate-pulse rounded-md", className)}
+ data-slot="skeleton"
+ {...props}
+ />
+ )
+}
+
+export { Skeleton }
diff --git a/packages/ui/components/sonner.tsx b/packages/ui/components/sonner.tsx
new file mode 100644
index 00000000..06307a08
--- /dev/null
+++ b/packages/ui/components/sonner.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+ <Sonner
+ className="toaster group"
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ } as React.CSSProperties
+ }
+ theme={theme as ToasterProps["theme"]}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/packages/ui/components/table.tsx b/packages/ui/components/table.tsx
new file mode 100644
index 00000000..91689860
--- /dev/null
+++ b/packages/ui/components/table.tsx
@@ -0,0 +1,115 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import type * as React from "react"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+ <div
+ className="relative w-full overflow-x-auto"
+ data-slot="table-container"
+ >
+ <table
+ className={cn("w-full caption-bottom text-sm", className)}
+ data-slot="table"
+ {...props}
+ />
+ </div>
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+ <thead
+ className={cn("[&_tr]:border-b", className)}
+ data-slot="table-header"
+ {...props}
+ />
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+ <tbody
+ className={cn("[&_tr:last-child]:border-0", className)}
+ data-slot="table-body"
+ {...props}
+ />
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ <tfoot
+ className={cn(
+ "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
+ className,
+ )}
+ data-slot="table-footer"
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+ <tr
+ className={cn(
+ "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+ className,
+ )}
+ data-slot="table-row"
+ {...props}
+ />
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ <th
+ className={cn(
+ "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ data-slot="table-head"
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ <td
+ className={cn(
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ data-slot="table-cell"
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+ <caption
+ className={cn("text-muted-foreground mt-4 text-sm", className)}
+ data-slot="table-caption"
+ {...props}
+ />
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/packages/ui/components/tabs.tsx b/packages/ui/components/tabs.tsx
new file mode 100644
index 00000000..77d27cdb
--- /dev/null
+++ b/packages/ui/components/tabs.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+import type * as React from "react"
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+ return (
+ <TabsPrimitive.Root
+ className={cn("flex flex-col gap-2", className)}
+ data-slot="tabs"
+ {...props}
+ />
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.List>) {
+ return (
+ <TabsPrimitive.List
+ className={cn(
+ "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1",
+ className,
+ )}
+ data-slot="tabs-list"
+ {...props}
+ />
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+ return (
+ <TabsPrimitive.Trigger
+ className={cn(
+ "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-2 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className,
+ )}
+ data-slot="tabs-trigger"
+ {...props}
+ />
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+ return (
+ <TabsPrimitive.Content
+ className={cn("flex-1 outline-none", className)}
+ data-slot="tabs-content"
+ {...props}
+ />
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/packages/ui/components/text-separator.tsx b/packages/ui/components/text-separator.tsx
new file mode 100644
index 00000000..7263d811
--- /dev/null
+++ b/packages/ui/components/text-separator.tsx
@@ -0,0 +1,24 @@
+import { cn } from "@lib/utils"
+
+interface TextSeparatorProps extends React.ComponentProps<"div"> {
+ text: string
+}
+
+export function TextSeparator({
+ text,
+ className,
+ ...props
+}: TextSeparatorProps) {
+ return (
+ <div
+ className={cn("flex gap-4 items-center justify-center", className)}
+ {...props}
+ >
+ <div className="w-full h-px bg-sm-gray" />
+ <span className="text-sm-gray text-[0.75rem] uppercase tracking-[-0.2px] leading-[0.875rem]">
+ {text}
+ </span>
+ <div className="w-full h-px bg-sm-gray" />
+ </div>
+ )
+}
diff --git a/packages/ui/components/textarea.tsx b/packages/ui/components/textarea.tsx
new file mode 100644
index 00000000..6ca3b1f8
--- /dev/null
+++ b/packages/ui/components/textarea.tsx
@@ -0,0 +1,17 @@
+import { cn } from "@lib/utils"
+import type * as React from "react"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+ <textarea
+ className={cn(
+ "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ className,
+ )}
+ data-slot="textarea"
+ {...props}
+ />
+ )
+}
+
+export { Textarea }
diff --git a/packages/ui/components/toggle-group.tsx b/packages/ui/components/toggle-group.tsx
new file mode 100644
index 00000000..b8621419
--- /dev/null
+++ b/packages/ui/components/toggle-group.tsx
@@ -0,0 +1,72 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
+import { toggleVariants } from "@ui/components/toggle"
+import type { VariantProps } from "class-variance-authority"
+import * as React from "react"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps<typeof toggleVariants>
+>({
+ size: "default",
+ variant: "default",
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ children,
+ ...props
+}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
+ VariantProps<typeof toggleVariants>) {
+ return (
+ <ToggleGroupPrimitive.Root
+ className={cn(
+ "group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
+ className,
+ )}
+ data-size={size}
+ data-slot="toggle-group"
+ data-variant={variant}
+ {...props}
+ >
+ <ToggleGroupContext.Provider value={{ variant, size }}>
+ {children}
+ </ToggleGroupContext.Provider>
+ </ToggleGroupPrimitive.Root>
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
+ VariantProps<typeof toggleVariants>) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+ <ToggleGroupPrimitive.Item
+ className={cn(
+ toggleVariants({
+ variant: context.variant || variant,
+ size: context.size || size,
+ }),
+ "min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
+ className,
+ )}
+ data-size={context.size || size}
+ data-slot="toggle-group-item"
+ data-variant={context.variant || variant}
+ {...props}
+ >
+ {children}
+ </ToggleGroupPrimitive.Item>
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/packages/ui/components/toggle.tsx b/packages/ui/components/toggle.tsx
new file mode 100644
index 00000000..ca855d28
--- /dev/null
+++ b/packages/ui/components/toggle.tsx
@@ -0,0 +1,46 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as TogglePrimitive from "@radix-ui/react-toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+import type * as React from "react"
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 px-2 min-w-9",
+ sm: "h-8 px-1.5 min-w-8",
+ lg: "h-10 px-2.5 min-w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+)
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps<typeof TogglePrimitive.Root> &
+ VariantProps<typeof toggleVariants>) {
+ return (
+ <TogglePrimitive.Root
+ className={cn(toggleVariants({ variant, size, className }))}
+ data-slot="toggle"
+ {...props}
+ />
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/packages/ui/components/tooltip.tsx b/packages/ui/components/tooltip.tsx
new file mode 100644
index 00000000..b06e9c8d
--- /dev/null
+++ b/packages/ui/components/tooltip.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+import type * as React from "react"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
+ return (
+ <TooltipPrimitive.Provider
+ data-slot="tooltip-provider"
+ delayDuration={delayDuration}
+ {...props}
+ />
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
+ return (
+ <TooltipProvider>
+ <TooltipPrimitive.Root data-slot="tooltip" {...props} />
+ </TooltipProvider>
+ )
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
+ return (
+ <TooltipPrimitive.Portal>
+ <TooltipPrimitive.Content
+ className={cn(
+ "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
+ className,
+ )}
+ data-slot="tooltip-content"
+ sideOffset={sideOffset}
+ {...props}
+ >
+ {children}
+ <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-sm" />
+ </TooltipPrimitive.Content>
+ </TooltipPrimitive.Portal>
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/packages/ui/copy-button.tsx b/packages/ui/copy-button.tsx
new file mode 100644
index 00000000..92ea4914
--- /dev/null
+++ b/packages/ui/copy-button.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { Button, type buttonVariants } from "@ui/components/button"
+import type { VariantProps } from "class-variance-authority"
+import { CheckIcon, ClipboardIcon } from "lucide-react"
+import * as React from "react"
+import { useEffect } from "react"
+
+interface CopyButtonProps
+ extends React.ComponentProps<"button">,
+ VariantProps<typeof buttonVariants> {
+ value: string
+ src?: string
+}
+
+export function CopyButton({
+ value,
+ className,
+ src,
+ variant = "ghost",
+ ...props
+}: CopyButtonProps) {
+ const [hasCopied, setHasCopied] = React.useState(false)
+
+ useEffect(() => {
+ setTimeout(() => {
+ setHasCopied(false)
+ }, 2000)
+ }, [])
+
+ return (
+ <Button
+ className={cn(
+ "relative z-10 text-zinc-50 hover:bg-zinc-700 hover:text-zinc-50 [&_svg]:h-full [&_svg]:w-full",
+ className,
+ )}
+ onClick={() => {
+ navigator.clipboard.writeText(value)
+ setHasCopied(true)
+ }}
+ size="icon"
+ variant={variant}
+ {...props}
+ >
+ <span className="sr-only">Copy</span>
+ {hasCopied ? <CheckIcon /> : <ClipboardIcon />}
+ </Button>
+ )
+}
diff --git a/packages/ui/copyable-cell.tsx b/packages/ui/copyable-cell.tsx
new file mode 100644
index 00000000..d23c9c6f
--- /dev/null
+++ b/packages/ui/copyable-cell.tsx
@@ -0,0 +1,84 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { Label1Regular } from "@ui/text/label/label-1-regular"
+import { AnimatePresence, motion } from "motion/react"
+import * as React from "react"
+
+interface CopyableCellProps extends React.HTMLAttributes<HTMLDivElement> {
+ value: string
+ displayValue?: React.ReactNode
+}
+
+export function CopyableCell({
+ value,
+ displayValue,
+ className,
+ children,
+ ...props
+}: CopyableCellProps) {
+ const [hasCopied, setHasCopied] = React.useState(false)
+
+ React.useEffect(() => {
+ if (hasCopied) {
+ const timeout = setTimeout(() => {
+ setHasCopied(false)
+ }, 2000)
+ return () => clearTimeout(timeout)
+ }
+ }, [hasCopied])
+
+ const handleCopy = async (e: React.MouseEvent) => {
+ e.stopPropagation()
+ try {
+ await navigator.clipboard.writeText(value)
+ setHasCopied(true)
+ } catch (err) {
+ console.error("Failed to copy:", err)
+ }
+ }
+
+ return (
+ // biome-ignore lint/a11y/noStaticElementInteractions: shadcn
+ // biome-ignore lint/a11y/useKeyWithClickEvents: shadcn
+ <div
+ className={cn(
+ "cursor-pointer transition-colors duration-200",
+ "hover:bg-zinc-800/50 hover:text-zinc-50",
+ "rounded px-2 py-1 -mx-2 -my-1",
+ "relative",
+ className,
+ )}
+ onClick={handleCopy}
+ {...props}
+ >
+ <AnimatePresence mode="wait">
+ {hasCopied ? (
+ <Label1Regular asChild className="block">
+ <motion.span
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: -10 }}
+ initial={{ opacity: 0, y: 10 }}
+ key="copied"
+ transition={{ duration: 0.2 }}
+ >
+ Copied!
+ </motion.span>
+ </Label1Regular>
+ ) : (
+ <Label1Regular asChild>
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: 10 }}
+ initial={{ opacity: 0, y: -10 }}
+ key="content"
+ transition={{ duration: 0.2 }}
+ >
+ {displayValue || children || value}
+ </motion.div>
+ </Label1Regular>
+ )}
+ </AnimatePresence>
+ </div>
+ )
+}
diff --git a/packages/ui/globals.css b/packages/ui/globals.css
new file mode 100644
index 00000000..b82b94da
--- /dev/null
+++ b/packages/ui/globals.css
@@ -0,0 +1,225 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../../apps/**/*.tsx";
+@source "../../packages/**/*.tsx";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--color-sm-shark-alt);
+ --color-background: var(--color-sm-shark-alt);
+ --color-foreground: var(--foreground);
+ --font-sans:
+ "Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-sans:
+ "Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-mono: var(--font-mono);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+
+ /* Design System */
+ --color-sm-alto: #dedede;
+ --color-sm-black: #000000;
+ --color-sm-boulder: #777777;
+ --color-sm-bunker: #090b0e;
+ --color-sm-charcoal: #2e2e2e;
+ --color-sm-dodger-blue: #267ffa;
+ --color-sm-chelsea-gem: #aa5d00;
+ --color-sm-dove-gray: #696969;
+ --color-sm-emperor: #545454;
+ --color-sm-gallery: #efefef;
+ --color-sm-gray: #888888;
+ --color-sm-heliotrope: #c472fb;
+ --color-sm-japanese-laurel: #008000;
+ --color-sm-mercury: #e1e1e1;
+ --color-sm-mine-shaft: #383838;
+ --color-sm-niagara: #0aa49f;
+ --color-sm-picton-blue: #3f96e6;
+ --color-sm-red-orange: #fb2c36;
+ --color-sm-seance: #7928a1;
+ --color-sm-selective-yellow: #ffb900;
+ --color-sm-shark: #1c2026;
+ --color-sm-shark-alt: #1c2026;
+ --color-sm-light-shark: #2b2e33;
+ --color-sm-silver-chalice: #b0b0b0;
+ --color-sm-tundora: #4d4d4d;
+ --color-sm-white: #ffffff;
+ --color-sm-white-alt: #fefefe;
+ --color-sm-wild-watermelon: #ff4d8d;
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.21 0.006 285.885);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.705 0.015 286.067);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.92 0.004 286.32);
+ --primary-foreground: oklch(0.21 0.006 285.885);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.552 0.016 285.938);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: var(--color-sm-black);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Custom Scrollbar Styles */
+.custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-sm-mine-shaft) transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar {
+ width: 6px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background-color: var(--color-sm-mine-shaft);
+ border-radius: 9999px;
+ transition: background-color 0.2s ease;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: var(--color-sm-tundora);
+}
+
+.custom-scrollbar::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
+/* Memory Drawer Scrollbar Styles */
+.memory-drawer-scroll::-webkit-scrollbar {
+ width: 6px;
+}
+
+.memory-drawer-scroll::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.memory-drawer-scroll::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+}
+
+.memory-drawer-scroll::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+/* Memory Sheet Scrollbar Styles */
+.memory-sheet-scroll::-webkit-scrollbar {
+ width: 8px;
+}
+
+.memory-sheet-scroll::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.memory-sheet-scroll::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+}
+
+.memory-sheet-scroll::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.15);
+}
diff --git a/packages/ui/input/labeled-input.tsx b/packages/ui/input/labeled-input.tsx
new file mode 100644
index 00000000..6f631392
--- /dev/null
+++ b/packages/ui/input/labeled-input.tsx
@@ -0,0 +1,42 @@
+import { cn } from "@lib/utils"
+import { Input } from "@ui/components/input"
+import { Label1Regular } from "@ui/text/label/label-1-regular"
+
+interface LabeledInputProps extends React.ComponentProps<"div"> {
+ label: string
+ inputType: string
+ inputPlaceholder: string
+ error?: string | null
+ inputProps?: React.ComponentProps<typeof Input>
+}
+
+export function LabeledInput({
+ label,
+ inputType,
+ inputPlaceholder,
+ className,
+ error,
+ inputProps,
+ ...props
+}: LabeledInputProps) {
+ return (
+ <div className={cn("flex flex-col gap-2", className)} {...props}>
+ <Label1Regular className="text-sm-white">{label}</Label1Regular>
+
+ <Input
+ className={cn(
+ "w-full leading-[1.375rem] tracking-[-0.4px] rounded-2xl p-5 placeholder:text-sm-gray text-sm-white border-[1.5px] border-sm-gray disabled:cursor-not-allowed disabled:opacity-50",
+ inputProps?.className,
+ )}
+ placeholder={inputPlaceholder}
+ type={inputType}
+ {...inputProps}
+ />
+ {error && (
+ <p className="text-sm text-red-500" role="alert">
+ {error}
+ </p>
+ )}
+ </div>
+ )
+}
diff --git a/packages/ui/memory-graph/constants.ts b/packages/ui/memory-graph/constants.ts
new file mode 100644
index 00000000..fddfdee5
--- /dev/null
+++ b/packages/ui/memory-graph/constants.ts
@@ -0,0 +1,100 @@
+// Enhanced glass-morphism color palette
+export const colors = {
+ background: {
+ primary: "#0f1419", // Deep dark blue-gray
+ secondary: "#1a1f29", // Slightly lighter
+ accent: "#252a35", // Card backgrounds
+ },
+ document: {
+ primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white
+ secondary: "rgba(255, 255, 255, 0.12)", // More visible
+ accent: "rgba(255, 255, 255, 0.18)", // Hover state
+ border: "rgba(255, 255, 255, 0.25)", // Sharp borders
+ glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction
+ },
+ memory: {
+ primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue
+ secondary: "rgba(147, 197, 253, 0.16)", // More visible
+ accent: "rgba(147, 197, 253, 0.24)", // Hover state
+ border: "rgba(147, 197, 253, 0.35)", // Sharp borders
+ glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction
+ },
+ connection: {
+ weak: "rgba(148, 163, 184, 0)", // Very subtle
+ memory: "rgba(148, 163, 184, 0.3)", // Very subtle
+ medium: "rgba(148, 163, 184, 0.125)", // Medium visibility
+ strong: "rgba(148, 163, 184, 0.4)", // Strong connection
+ },
+ text: {
+ primary: "#ffffff", // Pure white
+ secondary: "#e2e8f0", // Light gray
+ muted: "#94a3b8", // Medium gray
+ },
+ accent: {
+ primary: "rgba(59, 130, 246, 0.7)", // Clean blue
+ secondary: "rgba(99, 102, 241, 0.6)", // Clean purple
+ glow: "rgba(147, 197, 253, 0.6)", // Subtle glow
+ amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring
+ emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new
+ },
+ status: {
+ forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten
+ expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon
+ new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories
+ },
+ relations: {
+ updates: "rgba(147, 77, 253, 0.5)", // purple
+ extends: "rgba(16, 185, 129, 0.5)", // green
+ derives: "rgba(147, 197, 253, 0.5)", // blue
+ },
+}
+
+export const LAYOUT_CONSTANTS = {
+ centerX: 400,
+ centerY: 300,
+ clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble
+ spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out
+ documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out
+ minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius
+ memoryClusterRadius: 300,
+}
+
+// Graph view settings
+export const GRAPH_SETTINGS = {
+ console: {
+ initialZoom: 0.8, // Higher zoom for console - better overview
+ initialPanX: 0,
+ initialPanY: 0,
+ },
+ consumer: {
+ initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
+ initialPanX: 400, // Pan towards center to compensate for larger layout
+ initialPanY: 300, // Pan towards center to compensate for larger layout
+ },
+}
+
+// Responsive positioning for different app variants
+export const POSITIONING = {
+ console: {
+ legend: {
+ desktop: "bottom-4 right-4",
+ mobile: "bottom-4 right-4",
+ },
+ loadingIndicator: "top-20 right-4",
+
+ spacesSelector: "top-4 left-4",
+ viewToggle: "", // Not used in console
+ nodeDetail: "top-4 right-4",
+ },
+ consumer: {
+ legend: {
+ desktop: "top-18 right-4",
+ mobile: "bottom-[180px] left-4",
+ },
+ loadingIndicator: "top-20 right-4",
+
+ spacesSelector: "", // Hidden in consumer
+ viewToggle: "top-4 right-4", // Consumer has view toggle
+ nodeDetail: "top-4 right-4",
+ },
+}
diff --git a/packages/ui/memory-graph/controls.tsx b/packages/ui/memory-graph/controls.tsx
new file mode 100644
index 00000000..e3391210
--- /dev/null
+++ b/packages/ui/memory-graph/controls.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { cn } from "@repo/lib/utils"
+import { Button } from "@repo/ui/components/button"
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
+import { Move, ZoomIn, ZoomOut } from "lucide-react"
+import { memo } from "react"
+import type { ControlsProps } from "./types"
+
+export const Controls = memo<ControlsProps>(
+ ({ onZoomIn, onZoomOut, onResetView, variant = "console" }) => {
+ // Use explicit classes - controls positioning not defined in constants
+ // Using a reasonable default position
+ const getPositioningClasses = () => {
+ if (variant === "console") {
+ return "bottom-4 left-4"
+ }
+ if (variant === "consumer") {
+ return "bottom-20 right-4"
+ }
+ return ""
+ }
+
+ return (
+ <div
+ className={cn(
+ "absolute z-10 rounded-xl overflow-hidden",
+ getPositioningClasses(),
+ )}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10 px-4 py-3">
+ <div className="flex items-center gap-2">
+ <Button
+ className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
+ onClick={onZoomIn}
+ size="sm"
+ variant="ghost"
+ >
+ <ZoomIn className="w-4 h-4" />
+ </Button>
+ <Button
+ className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
+ onClick={onZoomOut}
+ size="sm"
+ variant="ghost"
+ >
+ <ZoomOut className="w-4 h-4" />
+ </Button>
+ <Button
+ className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
+ onClick={onResetView}
+ size="sm"
+ variant="ghost"
+ >
+ <Move className="w-4 h-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ )
+ },
+)
+
+Controls.displayName = "Controls"
diff --git a/packages/ui/memory-graph/graph-canvas.tsx b/packages/ui/memory-graph/graph-canvas.tsx
new file mode 100644
index 00000000..39d67b3f
--- /dev/null
+++ b/packages/ui/memory-graph/graph-canvas.tsx
@@ -0,0 +1,736 @@
+"use client"
+
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+} from "react"
+import { colors } from "./constants"
+import type {
+ DocumentWithMemories,
+ GraphCanvasProps,
+ GraphNode,
+ MemoryEntry,
+} from "./types"
+
+export const GraphCanvas = memo<GraphCanvasProps>(
+ ({
+ nodes,
+ edges,
+ panX,
+ panY,
+ zoom,
+ width,
+ height,
+ onNodeHover,
+ onNodeClick,
+ onNodeDragStart,
+ onNodeDragMove,
+ onNodeDragEnd,
+ onPanStart,
+ onPanMove,
+ onPanEnd,
+ onWheel,
+ onDoubleClick,
+ draggingNodeId,
+ highlightDocumentIds,
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(null)
+ const animationRef = useRef<number>(0)
+ const startTimeRef = useRef<number>(Date.now())
+ const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
+ const currentHoveredNode = useRef<string | null>(null)
+
+ // Initialize start time once
+ useEffect(() => {
+ startTimeRef.current = Date.now()
+ }, [])
+
+ // Efficient hit detection
+ const getNodeAtPosition = useCallback(
+ (x: number, y: number): string | null => {
+ // Check from top-most to bottom-most: memory nodes are drawn after documents
+ for (let i = nodes.length - 1; i >= 0; i--) {
+ const node = nodes[i]!
+ const screenX = node.x * zoom + panX
+ const screenY = node.y * zoom + panY
+ const nodeSize = node.size * zoom
+
+ const dx = x - screenX
+ const dy = y - screenY
+ const distance = Math.sqrt(dx * dx + dy * dy)
+
+ if (distance <= nodeSize / 2) {
+ return node.id
+ }
+ }
+ return null
+ },
+ [nodes, panX, panY, zoom],
+ )
+
+ // Handle mouse events
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const rect = canvas.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ mousePos.current = { x, y }
+
+ const nodeId = getNodeAtPosition(x, y)
+ if (nodeId !== currentHoveredNode.current) {
+ currentHoveredNode.current = nodeId
+ onNodeHover(nodeId)
+ }
+
+ // Handle node dragging
+ if (draggingNodeId) {
+ onNodeDragMove(e)
+ }
+ },
+ [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove],
+ )
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const rect = canvas.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ const nodeId = getNodeAtPosition(x, y)
+ if (nodeId) {
+ // When starting a node drag, prevent initiating pan
+ e.stopPropagation()
+ onNodeDragStart(nodeId, e)
+ return
+ }
+ onPanStart(e)
+ },
+ [getNodeAtPosition, onNodeDragStart, onPanStart],
+ )
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const rect = canvas.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ const nodeId = getNodeAtPosition(x, y)
+ if (nodeId) {
+ onNodeClick(nodeId)
+ }
+ },
+ [getNodeAtPosition, onNodeClick],
+ )
+
+ // Professional rendering function with LOD
+ const render = useCallback(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return
+
+ const currentTime = Date.now()
+ const _elapsed = currentTime - startTimeRef.current
+
+ // Level-of-detail optimization based on zoom
+ const useSimplifiedRendering = zoom < 0.3
+
+ // Clear canvas
+ ctx.clearRect(0, 0, width, height)
+
+ // Set high quality rendering
+ ctx.imageSmoothingEnabled = true
+ ctx.imageSmoothingQuality = "high"
+
+ // Draw minimal background grid
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" // Very subtle grid
+ ctx.lineWidth = 1
+ const gridSpacing = 100 * zoom
+ const offsetX = panX % gridSpacing
+ const offsetY = panY % gridSpacing
+
+ // Simple, clean grid lines
+ for (let x = offsetX; x < width; x += gridSpacing) {
+ ctx.beginPath()
+ ctx.moveTo(x, 0)
+ ctx.lineTo(x, height)
+ ctx.stroke()
+ }
+ for (let y = offsetY; y < height; y += gridSpacing) {
+ ctx.beginPath()
+ ctx.moveTo(0, y)
+ ctx.lineTo(width, y)
+ ctx.stroke()
+ }
+
+ // Create node lookup map
+ const nodeMap = new Map(nodes.map((node) => [node.id, node]))
+
+ // Draw enhanced edges with sophisticated styling
+ ctx.lineCap = "round"
+ edges.forEach((edge) => {
+ const sourceNode = nodeMap.get(edge.source)
+ const targetNode = nodeMap.get(edge.target)
+
+ if (sourceNode && targetNode) {
+ const sourceX = sourceNode.x * zoom + panX
+ const sourceY = sourceNode.y * zoom + panY
+ const targetX = targetNode.x * zoom + panX
+ const targetY = targetNode.y * zoom + panY
+
+ // Enhanced viewport culling with edge type considerations
+ if (
+ sourceX < -100 ||
+ sourceX > width + 100 ||
+ targetX < -100 ||
+ targetX > width + 100
+ ) {
+ return
+ }
+
+ // Skip very weak connections when zoomed out for performance
+ if (useSimplifiedRendering) {
+ if (
+ edge.edgeType === "doc-memory" &&
+ edge.visualProps.opacity < 0.3
+ ) {
+ return // Skip very weak doc-memory edges when zoomed out
+ }
+ }
+
+ // Enhanced connection styling based on edge type
+ let connectionColor = colors.connection.weak
+ let dashPattern: number[] = []
+ let opacity = edge.visualProps.opacity
+ let lineWidth = Math.max(1, edge.visualProps.thickness * zoom)
+
+ if (edge.edgeType === "doc-memory") {
+ // Doc-memory: Solid thin lines, subtle
+ dashPattern = []
+ connectionColor = colors.connection.memory
+ opacity = 0.9
+ lineWidth = 1
+ } else if (edge.edgeType === "doc-doc") {
+ // Doc-doc: Thick dashed lines with strong similarity emphasis
+ dashPattern = useSimplifiedRendering ? [] : [10, 5] // Solid lines when zoomed out
+ opacity = Math.max(0, edge.similarity * 0.5)
+ lineWidth = Math.max(1, edge.similarity * 2) // Thicker for stronger similarity
+
+ if (edge.similarity > 0.85)
+ connectionColor = colors.connection.strong;
+ else if (edge.similarity > 0.725)
+ connectionColor = colors.connection.medium;
+ } else if (edge.edgeType === "version") {
+ // Version chains: Double line effect with relation-specific colors
+ dashPattern = [];
+ connectionColor = edge.color || colors.relations.updates;
+ opacity = 0.8;
+ lineWidth = 2;
+ }
+
+ ctx.strokeStyle = connectionColor
+ ctx.lineWidth = lineWidth
+ ctx.globalAlpha = opacity
+ ctx.setLineDash(dashPattern)
+
+ if (edge.edgeType === "version") {
+ // Special double-line rendering for version chains
+ // First line (outer)
+ ctx.lineWidth = 3
+ ctx.globalAlpha = opacity * 0.3
+ ctx.beginPath()
+ ctx.moveTo(sourceX, sourceY)
+ ctx.lineTo(targetX, targetY)
+ ctx.stroke()
+
+ // Second line (inner)
+ ctx.lineWidth = 1
+ ctx.globalAlpha = opacity
+ ctx.beginPath()
+ ctx.moveTo(sourceX, sourceY)
+ ctx.lineTo(targetX, targetY)
+ ctx.stroke()
+ } else {
+ // Simplified lines when zoomed out, curved when zoomed in
+ if (useSimplifiedRendering) {
+ // Straight lines for performance
+ ctx.beginPath()
+ ctx.moveTo(sourceX, sourceY)
+ ctx.lineTo(targetX, targetY)
+ ctx.stroke()
+ } else {
+ // Regular curved line for doc-memory and doc-doc
+ const midX = (sourceX + targetX) / 2
+ const midY = (sourceY + targetY) / 2
+ const dx = targetX - sourceX
+ const dy = targetY - sourceY
+ const distance = Math.sqrt(dx * dx + dy * dy)
+ const controlOffset =
+ edge.edgeType === "doc-memory"
+ ? 15
+ : Math.min(30, distance * 0.2)
+
+ ctx.beginPath()
+ ctx.moveTo(sourceX, sourceY)
+ ctx.quadraticCurveTo(
+ midX + controlOffset * (dy / distance),
+ midY - controlOffset * (dx / distance),
+ targetX,
+ targetY,
+ )
+ ctx.stroke()
+ }
+ }
+
+ // Subtle arrow head for version edges
+ if (edge.edgeType === "version") {
+ const angle = Math.atan2(targetY - sourceY, targetX - sourceX);
+ const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle
+ const arrowWidth = Math.max(8, 12 * zoom);
+
+ // Calculate arrow position offset from node edge
+ const nodeRadius = (targetNode.size * zoom) / 2;
+ const offsetDistance = nodeRadius + 2;
+ const arrowX = targetX - Math.cos(angle) * offsetDistance;
+ const arrowY = targetY - Math.sin(angle) * offsetDistance;
+
+ ctx.save();
+ ctx.translate(arrowX, arrowY);
+ ctx.rotate(angle);
+ ctx.setLineDash([]);
+
+ // Simple outlined arrow (not filled)
+ ctx.strokeStyle = connectionColor;
+ ctx.lineWidth = Math.max(1, 1.5 * zoom);
+ ctx.globalAlpha = opacity;
+
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(-arrowLength, arrowWidth / 2);
+ ctx.moveTo(0, 0);
+ ctx.lineTo(-arrowLength, -arrowWidth / 2);
+ ctx.stroke();
+
+ ctx.restore();
+ }
+ }
+ })
+
+ ctx.globalAlpha = 1
+ ctx.setLineDash([])
+
+ // Prepare highlight set from provided document IDs (customId or internal)
+ const highlightSet = new Set<string>(highlightDocumentIds ?? [])
+
+ // Draw nodes with enhanced styling and LOD optimization
+ nodes.forEach((node) => {
+ const screenX = node.x * zoom + panX
+ const screenY = node.y * zoom + panY
+ const nodeSize = node.size * zoom
+
+ // Enhanced viewport culling
+ const margin = nodeSize + 50
+ if (
+ screenX < -margin ||
+ screenX > width + margin ||
+ screenY < -margin ||
+ screenY > height + margin
+ ) {
+ return
+ }
+
+ const isHovered = currentHoveredNode.current === node.id
+ const isDragging = node.isDragging
+ const isHighlightedDocument = (() => {
+ if (node.type !== "document" || highlightSet.size === 0) return false
+ const doc = node.data as DocumentWithMemories
+ if (doc.customId && highlightSet.has(doc.customId)) return true
+ return highlightSet.has(doc.id)
+ })()
+
+ if (node.type === "document") {
+ // Enhanced glassmorphism document styling
+ const docWidth = nodeSize * 1.4
+ const docHeight = nodeSize * 0.9
+
+ // Multi-layer glass effect
+ ctx.fillStyle = isDragging
+ ? colors.document.accent
+ : isHovered
+ ? colors.document.secondary
+ : colors.document.primary
+ ctx.globalAlpha = 1
+
+ // Enhanced border with subtle glow
+ ctx.strokeStyle = isDragging
+ ? colors.document.glow
+ : isHovered
+ ? colors.document.accent
+ : colors.document.border
+ ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1
+
+ // Rounded rectangle with enhanced styling
+ const radius = useSimplifiedRendering ? 6 : 12
+ ctx.beginPath()
+ ctx.roundRect(
+ screenX - docWidth / 2,
+ screenY - docHeight / 2,
+ docWidth,
+ docHeight,
+ radius,
+ )
+ ctx.fill()
+ ctx.stroke()
+
+ // Subtle inner highlight for glass effect (skip when zoomed out)
+ if (!useSimplifiedRendering && (isHovered || isDragging)) {
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.roundRect(
+ screenX - docWidth / 2 + 1,
+ screenY - docHeight / 2 + 1,
+ docWidth - 2,
+ docHeight - 2,
+ radius - 1,
+ )
+ ctx.stroke()
+ }
+
+ // Highlight ring for search hits
+ if (isHighlightedDocument) {
+ ctx.save()
+ ctx.globalAlpha = 0.9
+ ctx.strokeStyle = colors.accent.primary
+ ctx.lineWidth = 3
+ ctx.setLineDash([6, 4])
+ const ringPadding = 10
+ ctx.beginPath()
+ ctx.roundRect(
+ screenX - docWidth / 2 - ringPadding,
+ screenY - docHeight / 2 - ringPadding,
+ docWidth + ringPadding * 2,
+ docHeight + ringPadding * 2,
+ radius + 6,
+ )
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.restore()
+ }
+ } else {
+ // Enhanced memory styling with status indicators
+ const mem = node.data as MemoryEntry;
+ const isForgotten = mem.isForgotten || (mem.forgetAfter && new Date(mem.forgetAfter).getTime() < Date.now());
+ const isLatest = mem.isLatest;
+
+ // Check if memory is expiring soon (within 7 days)
+ const expiringSoon =
+ mem.forgetAfter &&
+ !isForgotten &&
+ new Date(mem.forgetAfter).getTime() - Date.now() <
+ 1000 * 60 * 60 * 24 * 7
+
+ // Check if memory is new (created within last 24 hours)
+ const isNew =
+ !isForgotten &&
+ new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24
+
+ // Determine colors based on status
+ let fillColor = colors.memory.primary
+ let borderColor = colors.memory.border
+ let glowColor = colors.memory.glow
+
+ if (isForgotten) {
+ fillColor = colors.status.forgotten;
+ borderColor = "rgba(220,38,38,0.3)";
+ glowColor = "rgba(220,38,38,0.2)";
+ } else if (expiringSoon) {
+ borderColor = colors.status.expiring
+ glowColor = colors.accent.amber
+ } else if (isNew) {
+ borderColor = colors.status.new
+ glowColor = colors.accent.emerald
+ }
+
+ if (isDragging) {
+ fillColor = colors.memory.accent
+ borderColor = glowColor
+ } else if (isHovered) {
+ fillColor = colors.memory.secondary
+ }
+
+ const radius = nodeSize / 2
+
+ ctx.fillStyle = fillColor
+ ctx.globalAlpha = isLatest ? 1 : 0.4
+ ctx.strokeStyle = borderColor
+ ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5
+
+ if (useSimplifiedRendering) {
+ // Simple circles when zoomed out for performance
+ ctx.beginPath()
+ ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI)
+ ctx.fill()
+ ctx.stroke()
+ } else {
+ // HEXAGONAL memory nodes when zoomed in
+ const sides = 6
+ ctx.beginPath()
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 // Start from top
+ const x = screenX + radius * Math.cos(angle)
+ const y = screenY + radius * Math.sin(angle)
+ if (i === 0) {
+ ctx.moveTo(x, y)
+ } else {
+ ctx.lineTo(x, y)
+ }
+ }
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+
+ // Inner highlight for glass effect
+ if (isHovered || isDragging) {
+ ctx.strokeStyle = "rgba(147, 197, 253, 0.3)"
+ ctx.lineWidth = 1
+ const innerRadius = radius - 2
+ ctx.beginPath()
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
+ const x = screenX + innerRadius * Math.cos(angle)
+ const y = screenY + innerRadius * Math.sin(angle)
+ if (i === 0) {
+ ctx.moveTo(x, y)
+ } else {
+ ctx.lineTo(x, y)
+ }
+ }
+ ctx.closePath()
+ ctx.stroke()
+ }
+ }
+
+ // Status indicators overlay (always preserve these as required)
+ if (isForgotten) {
+ // Cross for forgotten memories
+ ctx.strokeStyle = "rgba(220,38,38,0.4)";
+ ctx.lineWidth = 2;
+ const r = nodeSize * 0.25;
+ ctx.beginPath();
+ ctx.moveTo(screenX - r, screenY - r);
+ ctx.lineTo(screenX + r, screenY + r);
+ ctx.moveTo(screenX + r, screenY - r);
+ ctx.lineTo(screenX - r, screenY + r);
+ ctx.stroke();
+ } else if (isNew) {
+ // Small dot for new memories
+ ctx.fillStyle = colors.status.new
+ ctx.beginPath()
+ ctx.arc(
+ screenX + nodeSize * 0.25,
+ screenY - nodeSize * 0.25,
+ Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px
+ 0,
+ 2 * Math.PI,
+ )
+ ctx.fill()
+ }
+ }
+
+ // Enhanced hover glow effect (skip when zoomed out for performance)
+ if (!useSimplifiedRendering && (isHovered || isDragging)) {
+ const glowColor =
+ node.type === "document" ? colors.document.glow : colors.memory.glow
+
+ ctx.strokeStyle = glowColor
+ ctx.lineWidth = 1
+ ctx.setLineDash([3, 3])
+ ctx.globalAlpha = 0.6
+
+ ctx.beginPath()
+ const glowSize = nodeSize * 0.7
+ if (node.type === "document") {
+ ctx.roundRect(
+ screenX - glowSize,
+ screenY - glowSize / 1.4,
+ glowSize * 2,
+ glowSize * 1.4,
+ 15,
+ )
+ } else {
+ // Hexagonal glow for memory nodes
+ const glowRadius = glowSize
+ const sides = 6
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
+ const x = screenX + glowRadius * Math.cos(angle)
+ const y = screenY + glowRadius * Math.sin(angle)
+ if (i === 0) {
+ ctx.moveTo(x, y)
+ } else {
+ ctx.lineTo(x, y)
+ }
+ }
+ ctx.closePath()
+ }
+ ctx.stroke()
+ ctx.setLineDash([])
+ }
+ })
+
+ ctx.globalAlpha = 1
+ }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds])
+
+ // Change-based rendering instead of continuous animation
+ const lastRenderParams = useRef<string>("")
+
+ // Create a render key that changes when visual state changes
+ const renderKey = useMemo(() => {
+ const nodePositions = nodes
+ .map(
+ (n) =>
+ `${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`,
+ )
+ .join("|")
+ const highlightKey = (highlightDocumentIds ?? []).join("|")
+ return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`
+ }, [nodes, edges.length, panX, panY, zoom, width, height, highlightDocumentIds])
+
+ // Only render when something actually changed
+ useEffect(() => {
+ if (renderKey !== lastRenderParams.current) {
+ lastRenderParams.current = renderKey
+ render()
+ }
+ }, [renderKey, render])
+
+ // Cleanup any existing animation frames
+ useEffect(() => {
+ return () => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current)
+ }
+ }
+ }, [])
+
+ // Add native wheel event listener to prevent browser zoom
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const handleNativeWheel = (e: WheelEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ // Call the onWheel handler with a synthetic-like event
+ onWheel({
+ deltaY: e.deltaY,
+ deltaX: e.deltaX,
+ preventDefault: () => { },
+ stopPropagation: () => { },
+ } as React.WheelEvent)
+ }
+
+ // Add listener with passive: false to ensure preventDefault works
+ canvas.addEventListener("wheel", handleNativeWheel, { passive: false })
+
+ // Also prevent gesture events for touch devices
+ const handleGesture = (e: Event) => {
+ e.preventDefault()
+ }
+
+ canvas.addEventListener("gesturestart", handleGesture, {
+ passive: false,
+ })
+ canvas.addEventListener("gesturechange", handleGesture, {
+ passive: false,
+ })
+ canvas.addEventListener("gestureend", handleGesture, { passive: false })
+
+ return () => {
+ canvas.removeEventListener("wheel", handleNativeWheel)
+ canvas.removeEventListener("gesturestart", handleGesture)
+ canvas.removeEventListener("gesturechange", handleGesture)
+ canvas.removeEventListener("gestureend", handleGesture)
+ }
+ }, [onWheel])
+
+ // High-DPI handling --------------------------------------------------
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
+
+ useLayoutEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ // upscale backing store
+ canvas.style.width = `${width}px`
+ canvas.style.height = `${height}px`
+ canvas.width = width * dpr
+ canvas.height = height * dpr
+
+ const ctx = canvas.getContext("2d")
+ ctx?.scale(dpr, dpr)
+ }, [width, height, dpr])
+ // -----------------------------------------------------------------------
+
+ return (
+ <canvas
+ className="absolute inset-0"
+ height={height}
+ onClick={handleClick}
+ onDoubleClick={onDoubleClick}
+ onMouseDown={handleMouseDown}
+ onMouseLeave={() => {
+ if (draggingNodeId) {
+ onNodeDragEnd()
+ } else {
+ onPanEnd()
+ }
+ }}
+ onMouseMove={(e) => {
+ handleMouseMove(e)
+ if (!draggingNodeId) {
+ onPanMove(e)
+ }
+ }}
+ onMouseUp={() => {
+ if (draggingNodeId) {
+ onNodeDragEnd()
+ } else {
+ onPanEnd()
+ }
+ }}
+ ref={canvasRef}
+ style={{
+ cursor: draggingNodeId
+ ? "grabbing"
+ : currentHoveredNode.current
+ ? "grab"
+ : "move",
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ }}
+ width={width}
+ />
+ )
+ },
+)
+
+GraphCanvas.displayName = "GraphCanvas"
diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx
new file mode 100644
index 00000000..41b79343
--- /dev/null
+++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx
@@ -0,0 +1,781 @@
+"use client"
+
+import { Application, extend } from "@pixi/react"
+import { Container as PixiContainer, Graphics as PixiGraphics } from "pixi.js"
+import { memo, useCallback, useEffect, useMemo, useRef } from "react"
+import { colors } from "./constants"
+import type { GraphCanvasProps, MemoryEntry } from "./types"
+
+// Register Pixi Graphics and Container so they can be used as JSX elements
+extend({ Graphics: PixiGraphics, Container: PixiContainer })
+
+export const GraphWebGLCanvas = memo<GraphCanvasProps>(
+ ({
+ nodes,
+ edges,
+ panX,
+ panY,
+ zoom,
+ width,
+ height,
+ onNodeHover,
+ onNodeClick,
+ onNodeDragStart,
+ onNodeDragMove,
+ onNodeDragEnd,
+ onPanStart,
+ onPanMove,
+ onPanEnd,
+ onWheel,
+ onDoubleClick,
+ draggingNodeId,
+ }) => {
+ const containerRef = useRef<HTMLDivElement>(null)
+ const isPanningRef = useRef(false)
+ const currentHoveredRef = useRef<string | null>(null)
+ const pointerDownPosRef = useRef<{ x: number; y: number } | null>(null)
+ const pointerMovedRef = useRef(false)
+ // World container that is transformed instead of redrawing every pan/zoom
+ const worldContainerRef = useRef<PixiContainer | null>(null)
+
+ // Throttled wheel handling -------------------------------------------
+ const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({
+ dx: 0,
+ dy: 0,
+ })
+ const wheelRafRef = useRef<number | null>(null)
+ // Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance
+
+ // Persistent graphics refs
+ const gridG = useRef<PixiGraphics | null>(null)
+ const edgesG = useRef<PixiGraphics | null>(null)
+ const docsG = useRef<PixiGraphics | null>(null)
+ const memsG = useRef<PixiGraphics | null>(null)
+
+ // ---------- Zoom bucket (reduces redraw frequency) ----------
+ const zoomBucket = useMemo(() => Math.round(zoom * 4) / 4, [zoom])
+
+ // Redraw layers only when their data changes ----------------------
+ useEffect(() => {
+ if (gridG.current) drawGrid(gridG.current)
+ }, [panX, panY, zoom, width, height])
+
+ useEffect(() => {
+ if (edgesG.current) drawEdges(edgesG.current)
+ }, [edgesG.current, edges, nodes, zoomBucket])
+
+ useEffect(() => {
+ if (docsG.current) drawDocuments(docsG.current)
+ }, [docsG.current, nodes, zoomBucket])
+
+ useEffect(() => {
+ if (memsG.current) drawMemories(memsG.current)
+ }, [memsG.current, nodes, zoomBucket])
+
+ // Apply pan & zoom via world transform instead of geometry rebuilds
+ useEffect(() => {
+ if (worldContainerRef.current) {
+ worldContainerRef.current.position.set(panX, panY)
+ worldContainerRef.current.scale.set(zoom)
+ }
+ }, [panX, panY, zoom])
+
+ // No bitmap caching – nothing to clean up
+
+ /* ---------- Helpers ---------- */
+ const getNodeAtPosition = useCallback(
+ (clientX: number, clientY: number): string | null => {
+ const rect = containerRef.current?.getBoundingClientRect()
+ if (!rect) return null
+
+ const localX = clientX - rect.left
+ const localY = clientY - rect.top
+
+ const worldX = (localX - panX) / zoom
+ const worldY = (localY - panY) / zoom
+
+ for (const node of nodes) {
+ if (node.type === "document") {
+ const halfW = (node.size * 1.4) / 2
+ const halfH = (node.size * 0.9) / 2
+ if (
+ worldX >= node.x - halfW &&
+ worldX <= node.x + halfW &&
+ worldY >= node.y - halfH &&
+ worldY <= node.y + halfH
+ ) {
+ return node.id
+ }
+ } else if (node.type === "memory") {
+ const r = node.size / 2
+ const dx = worldX - node.x
+ const dy = worldY - node.y
+ if (dx * dx + dy * dy <= r * r) {
+ return node.id
+ }
+ }
+ }
+ return null
+ },
+ [nodes, panX, panY, zoom],
+ )
+
+ /* ---------- Grid drawing ---------- */
+ const drawGrid = useCallback(
+ (g: PixiGraphics) => {
+ g.clear()
+
+ const gridColor = 0x94a3b8 // rgb(148,163,184)
+ const gridAlpha = 0.03
+ const gridSpacing = 100 * zoom
+
+ // panning offsets
+ const offsetX = panX % gridSpacing
+ const offsetY = panY % gridSpacing
+
+ g.lineStyle(1, gridColor, gridAlpha)
+
+ // vertical lines
+ for (let x = offsetX; x < width; x += gridSpacing) {
+ g.moveTo(x, 0)
+ g.lineTo(x, height)
+ }
+
+ // horizontal lines
+ for (let y = offsetY; y < height; y += gridSpacing) {
+ g.moveTo(0, y)
+ g.lineTo(width, y)
+ }
+
+ // Stroke to render grid lines
+ g.stroke()
+ },
+ [panX, panY, zoom, width, height],
+ )
+
+ /* ---------- Color parsing ---------- */
+ const toHexAlpha = (input: string): { hex: number; alpha: number } => {
+ if (!input) return { hex: 0xffffff, alpha: 1 }
+ const str = input.trim().toLowerCase()
+ // rgba() or rgb()
+ const rgbaMatch = str
+ .replace(/\s+/g, "")
+ .match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\)/i)
+ if (rgbaMatch) {
+ const r = Number.parseInt(rgbaMatch[1] || '0')
+ const g = Number.parseInt(rgbaMatch[2] || '0')
+ const b = Number.parseInt(rgbaMatch[3] || '0')
+ const a =
+ rgbaMatch[4] !== undefined ? Number.parseFloat(rgbaMatch[4]) : 1
+ return { hex: (r << 16) + (g << 8) + b, alpha: a }
+ }
+ // #rrggbb or #rrggbbaa
+ if (str.startsWith("#")) {
+ const hexBody = str.slice(1)
+ if (hexBody.length === 6) {
+ return { hex: Number.parseInt(hexBody, 16), alpha: 1 }
+ }
+ if (hexBody.length === 8) {
+ const rgb = Number.parseInt(hexBody.slice(0, 6), 16)
+ const aByte = Number.parseInt(hexBody.slice(6, 8), 16)
+ return { hex: rgb, alpha: aByte / 255 }
+ }
+ }
+ // 0xRRGGBB
+ if (str.startsWith("0x")) {
+ return { hex: Number.parseInt(str, 16), alpha: 1 }
+ }
+ return { hex: 0xffffff, alpha: 1 }
+ }
+
+ const drawDocuments = useCallback(
+ (g: PixiGraphics) => {
+ g.clear()
+
+ nodes.forEach((node) => {
+ if (node.type !== "document") return
+
+ // World-space coordinates – container transform handles pan/zoom
+ const screenX = node.x
+ const screenY = node.y
+ const nodeSize = node.size
+
+ const docWidth = nodeSize * 1.4
+ const docHeight = nodeSize * 0.9
+
+ // Choose colors similar to canvas version
+ const fill = node.isDragging
+ ? colors.document.accent
+ : node.isHovered
+ ? colors.document.secondary
+ : colors.document.primary
+
+ const strokeCol = node.isDragging
+ ? colors.document.glow
+ : node.isHovered
+ ? colors.document.accent
+ : colors.document.border
+
+ const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fill)
+ const { hex: strokeHex, alpha: strokeAlpha } = toHexAlpha(strokeCol)
+
+ // Stroke first then fill for proper shape borders
+ const docStrokeWidth =
+ (node.isDragging ? 3 : node.isHovered ? 2 : 1) / zoom
+ g.lineStyle(docStrokeWidth, strokeHex, strokeAlpha)
+ g.beginFill(fillHex, fillAlpha)
+
+ const radius = zoom < 0.3 ? 6 : 12
+ g.drawRoundedRect(
+ screenX - docWidth / 2,
+ screenY - docHeight / 2,
+ docWidth,
+ docHeight,
+ radius,
+ )
+ g.endFill()
+
+ // Inner highlight for glass effect (match GraphCanvas)
+ if (zoom >= 0.3 && (node.isHovered || node.isDragging)) {
+ const { hex: hlHex } = toHexAlpha("#ffffff")
+ // Inner highlight stroke width constant
+ const innerStroke = 1 / zoom
+ g.lineStyle(innerStroke, hlHex, 0.1)
+ g.drawRoundedRect(
+ screenX - docWidth / 2 + 1,
+ screenY - docHeight / 2 + 1,
+ docWidth - 2,
+ docHeight - 2,
+ radius - 1,
+ )
+ g.stroke()
+ }
+ })
+ },
+ [nodes, zoom],
+ )
+
+ /* ---------- Memories layer ---------- */
+ const drawMemories = useCallback(
+ (g: PixiGraphics) => {
+ g.clear()
+
+ nodes.forEach((node) => {
+ if (node.type !== "memory") return
+
+ const mem = node.data as MemoryEntry
+ const screenX = node.x
+ const screenY = node.y
+ const nodeSize = node.size
+
+ const radius = nodeSize / 2
+
+ // status checks
+ const isForgotten =
+ mem?.isForgotten ||
+ (mem?.forgetAfter &&
+ new Date(mem.forgetAfter).getTime() < Date.now())
+ const isLatest = mem?.isLatest
+ const expiringSoon =
+ mem?.forgetAfter &&
+ !isForgotten &&
+ new Date(mem.forgetAfter).getTime() - Date.now() <
+ 1000 * 60 * 60 * 24 * 7
+ const isNew =
+ !isForgotten &&
+ new Date(mem?.createdAt).getTime() >
+ Date.now() - 1000 * 60 * 60 * 24
+
+ // colours
+ let fillColor = colors.memory.primary
+ let borderColor = colors.memory.border
+ let glowColor = colors.memory.glow
+
+ if (isForgotten) {
+ fillColor = colors.status.forgotten
+ borderColor = "rgba(220,38,38,0.3)"
+ glowColor = "rgba(220,38,38,0.2)"
+ } else if (expiringSoon) {
+ borderColor = colors.status.expiring
+ glowColor = colors.accent.amber
+ } else if (isNew) {
+ borderColor = colors.status.new
+ glowColor = colors.accent.emerald
+ }
+
+ if (node.isDragging) {
+ fillColor = colors.memory.accent
+ borderColor = glowColor
+ } else if (node.isHovered) {
+ fillColor = colors.memory.secondary
+ }
+
+ const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fillColor)
+ const { hex: borderHex, alpha: borderAlpha } = toHexAlpha(borderColor)
+
+ // Match canvas behavior: multiply by isLatest global alpha
+ const globalAlpha = isLatest ? 1 : 0.4
+ const finalFillAlpha = globalAlpha * fillAlpha
+ const finalStrokeAlpha = globalAlpha * borderAlpha
+ // Stroke first then fill for visible border
+ const memStrokeW =
+ (node.isDragging ? 3 : node.isHovered ? 2 : 1.5) / zoom
+ g.lineStyle(memStrokeW, borderHex, finalStrokeAlpha)
+ g.beginFill(fillHex, finalFillAlpha)
+
+ if (zoom < 0.3) {
+ // simplified circle when zoomed out
+ g.drawCircle(screenX, screenY, radius)
+ } else {
+ // hexagon
+ const sides = 6
+ const points: number[] = []
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
+ points.push(screenX + radius * Math.cos(angle))
+ points.push(screenY + radius * Math.sin(angle))
+ }
+ g.drawPolygon(points)
+ }
+
+ g.endFill()
+
+ // Status overlays (forgotten / new) – match GraphCanvas visuals
+ if (isForgotten) {
+ const { hex: crossHex, alpha: crossAlpha } = toHexAlpha(
+ "rgba(220,38,38,0.4)",
+ )
+ // Cross/ dot overlay stroke widths constant
+ const overlayStroke = 2 / zoom
+ g.lineStyle(overlayStroke, crossHex, globalAlpha * crossAlpha)
+ const rCross = nodeSize * 0.25
+ g.moveTo(screenX - rCross, screenY - rCross)
+ g.lineTo(screenX + rCross, screenY + rCross)
+ g.moveTo(screenX + rCross, screenY - rCross)
+ g.lineTo(screenX - rCross, screenY + rCross)
+ g.stroke()
+ } else if (isNew) {
+ const { hex: dotHex, alpha: dotAlpha } = toHexAlpha(
+ colors.status.new,
+ )
+ // Dot scales with node (GraphCanvas behaviour)
+ const dotRadius = Math.max(2, nodeSize * 0.15)
+ g.beginFill(dotHex, globalAlpha * dotAlpha)
+ g.drawCircle(
+ screenX + nodeSize * 0.25,
+ screenY - nodeSize * 0.25,
+ dotRadius,
+ )
+ g.endFill()
+ }
+ })
+ },
+ [nodes, zoom],
+ )
+
+ /* ---------- Edges layer ---------- */
+ // Helper: draw dashed quadratic curve to approximate canvas setLineDash
+ const drawDashedQuadratic = useCallback(
+ (
+ g: PixiGraphics,
+ sx: number,
+ sy: number,
+ cx: number,
+ cy: number,
+ tx: number,
+ ty: number,
+ dash = 10,
+ gap = 5,
+ ) => {
+ // Sample the curve and accumulate lines per dash to avoid overdraw
+ const curveLength = Math.sqrt((sx - tx) ** 2 + (sy - ty) ** 2)
+ const totalSamples = Math.max(
+ 20,
+ Math.min(120, Math.floor(curveLength / 10)),
+ )
+ let prevX = sx
+ let prevY = sy
+ let distanceSinceToggle = 0
+ let drawSegment = true
+ let hasActiveDash = false
+ let dashStartX = sx
+ let dashStartY = sy
+
+ for (let i = 1; i <= totalSamples; i++) {
+ const t = i / totalSamples
+ const mt = 1 - t
+ const x = mt * mt * sx + 2 * mt * t * cx + t * t * tx
+ const y = mt * mt * sy + 2 * mt * t * cy + t * t * ty
+
+ const dx = x - prevX
+ const dy = y - prevY
+ const segLen = Math.sqrt(dx * dx + dy * dy)
+ distanceSinceToggle += segLen
+
+ if (drawSegment) {
+ if (!hasActiveDash) {
+ dashStartX = prevX
+ dashStartY = prevY
+ hasActiveDash = true
+ }
+ }
+
+ const threshold = drawSegment ? dash : gap
+ if (distanceSinceToggle >= threshold) {
+ // end of current phase
+ if (drawSegment && hasActiveDash) {
+ g.moveTo(dashStartX, dashStartY)
+ g.lineTo(prevX, prevY)
+ g.stroke()
+ hasActiveDash = false
+ }
+ distanceSinceToggle = 0
+ drawSegment = !drawSegment
+ // If we transition into draw mode, start a new dash at current segment start
+ if (drawSegment) {
+ dashStartX = prevX
+ dashStartY = prevY
+ hasActiveDash = true
+ }
+ }
+
+ prevX = x
+ prevY = y
+ }
+
+ // Flush any active dash at the end
+ if (drawSegment && hasActiveDash) {
+ g.moveTo(dashStartX, dashStartY)
+ g.lineTo(prevX, prevY)
+ g.stroke()
+ }
+ },
+ [],
+ )
+ const drawEdges = useCallback(
+ (g: PixiGraphics) => {
+ g.clear()
+
+ // Match GraphCanvas LOD behaviour
+ const useSimplified = zoom < 0.3
+
+ // quick node lookup
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]))
+
+ edges.forEach((edge) => {
+ // Skip very weak doc-memory edges when zoomed out – behaviour copied from GraphCanvas
+ if (
+ useSimplified &&
+ edge.edgeType === "doc-memory" &&
+ (edge.visualProps?.opacity ?? 1) < 0.3
+ ) {
+ return
+ }
+ const source = nodeMap.get(edge.source)
+ const target = nodeMap.get(edge.target)
+ if (!source || !target) return
+
+ const sx = source.x
+ const sy = source.y
+ const tx = target.x
+ const ty = target.y
+
+ // No viewport culling here because container transform handles visibility
+
+ let lineWidth = Math.max(1, edge.visualProps?.thickness ?? 1)
+ // Use opacity exactly as provided to match GraphCanvas behaviour
+ let opacity = edge.visualProps.opacity
+ let col = edge.color || colors.connection.weak
+
+ if (edge.edgeType === "doc-memory") {
+ lineWidth = 1
+ opacity = 0.9
+ col = colors.connection.memory
+
+ if (useSimplified && opacity < 0.3) return
+ } else if (edge.edgeType === "doc-doc") {
+ opacity = Math.max(0, edge.similarity * 0.5)
+ lineWidth = Math.max(1, edge.similarity * 2)
+ col = colors.connection.medium
+ if (edge.similarity > 0.85) col = colors.connection.strong
+ } else if (edge.edgeType === "version") {
+ col = edge.color || colors.relations.updates
+ opacity = 0.8
+ lineWidth = 2
+ }
+
+ const { hex: strokeHex, alpha: colorAlpha } = toHexAlpha(col)
+ const finalEdgeAlpha = Math.max(0, Math.min(1, opacity * colorAlpha))
+
+ // Always use round line caps (same as Canvas 2D)
+ const screenLineWidth = lineWidth / zoom
+ g.lineStyle(screenLineWidth, strokeHex, finalEdgeAlpha)
+
+ if (edge.edgeType === "version") {
+ // double line effect to match canvas (outer thicker, faint + inner thin)
+ g.lineStyle(3 / zoom, strokeHex, finalEdgeAlpha * 0.3)
+ g.moveTo(sx, sy)
+ g.lineTo(tx, ty)
+ g.stroke()
+
+ g.lineStyle(1 / zoom, strokeHex, finalEdgeAlpha)
+ g.moveTo(sx, sy)
+ g.lineTo(tx, ty)
+ g.stroke()
+
+ // arrow head
+ const angle = Math.atan2(ty - sy, tx - sx)
+ const arrowLen = Math.max(6 / zoom, 8)
+ const nodeRadius = target.size / 2
+ const ax = tx - Math.cos(angle) * (nodeRadius + 2)
+ const ay = ty - Math.sin(angle) * (nodeRadius + 2)
+
+ g.moveTo(ax, ay)
+ g.lineTo(
+ ax - arrowLen * Math.cos(angle - Math.PI / 6),
+ ay - arrowLen * Math.sin(angle - Math.PI / 6),
+ )
+ g.moveTo(ax, ay)
+ g.lineTo(
+ ax - arrowLen * Math.cos(angle + Math.PI / 6),
+ ay - arrowLen * Math.sin(angle + Math.PI / 6),
+ )
+ g.stroke()
+ } else {
+ // straight line when zoomed out; dashed curved when zoomed in for doc-doc
+ if (useSimplified) {
+ g.moveTo(sx, sy)
+ g.lineTo(tx, ty)
+ g.stroke()
+ } else {
+ const midX = (sx + tx) / 2
+ const midY = (sy + ty) / 2
+ const dx = tx - sx
+ const dy = ty - sy
+ const dist = Math.sqrt(dx * dx + dy * dy)
+ const ctrlOffset =
+ edge.edgeType === "doc-memory" ? 15 : Math.min(30, dist * 0.2)
+
+ const cx = midX + ctrlOffset * (dy / dist)
+ const cy = midY - ctrlOffset * (dx / dist)
+
+ if (edge.edgeType === "doc-doc") {
+ if (useSimplified) {
+ // Straight line when zoomed out (no dash)
+ g.moveTo(sx, sy)
+ g.quadraticCurveTo(cx, cy, tx, ty)
+ g.stroke()
+ } else {
+ // Dash lengths scale with zoom to keep screen size constant
+ const dash = 10 / zoom
+ const gap = 5 / zoom
+ drawDashedQuadratic(g, sx, sy, cx, cy, tx, ty, dash, gap)
+ }
+ } else {
+ g.moveTo(sx, sy)
+ g.quadraticCurveTo(cx, cy, tx, ty)
+ g.stroke()
+ }
+ }
+ }
+ })
+ },
+ [edges, nodes, zoom, width, drawDashedQuadratic],
+ )
+
+ /* ---------- pointer handlers (unchanged) ---------- */
+ // Pointer move (pan or drag)
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const mouseEvent = {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.MouseEvent
+
+ if (draggingNodeId) {
+ // Node dragging handled elsewhere (future steps)
+ onNodeDragMove(mouseEvent)
+ } else if (isPanningRef.current) {
+ onPanMove(mouseEvent)
+ }
+
+ // Track movement for distinguishing click vs drag/pan
+ if (pointerDownPosRef.current) {
+ const dx = e.clientX - pointerDownPosRef.current.x
+ const dy = e.clientY - pointerDownPosRef.current.y
+ if (Math.sqrt(dx * dx + dy * dy) > 3) pointerMovedRef.current = true
+ }
+
+ // Hover detection
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY)
+ if (nodeId !== currentHoveredRef.current) {
+ currentHoveredRef.current = nodeId
+ onNodeHover(nodeId)
+ }
+ },
+ [
+ draggingNodeId,
+ onNodeDragMove,
+ onPanMove,
+ onNodeHover,
+ getNodeAtPosition,
+ ],
+ )
+
+ const handlePointerDown = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const mouseEvent = {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.MouseEvent
+
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY)
+ if (nodeId) {
+ onNodeDragStart(nodeId, mouseEvent)
+ // drag handled externally
+ } else {
+ onPanStart(mouseEvent)
+ isPanningRef.current = true
+ }
+ pointerDownPosRef.current = { x: e.clientX, y: e.clientY }
+ pointerMovedRef.current = false
+ },
+ [onPanStart, onNodeDragStart, getNodeAtPosition],
+ )
+
+ const handlePointerUp = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const wasPanning = isPanningRef.current
+ if (draggingNodeId) onNodeDragEnd()
+ else if (wasPanning) onPanEnd()
+
+ // Consider it a click if not panning and movement was minimal
+ if (!wasPanning && !pointerMovedRef.current) {
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY)
+ if (nodeId) onNodeClick(nodeId)
+ }
+
+ isPanningRef.current = false
+ pointerDownPosRef.current = null
+ pointerMovedRef.current = false
+ },
+ [draggingNodeId, onNodeDragEnd, onPanEnd, getNodeAtPosition, onNodeClick],
+ )
+
+ // Click handler – opens detail panel
+ const handleClick = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ if (isPanningRef.current) return
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY)
+ if (nodeId) onNodeClick(nodeId)
+ },
+ [getNodeAtPosition, onNodeClick],
+ )
+
+ // Click handled in pointer up to avoid duplicate events
+
+ const handleWheel = useCallback(
+ (e: React.WheelEvent<HTMLDivElement>) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ // Accumulate deltas
+ pendingWheelDeltaRef.current.dx += e.deltaX
+ pendingWheelDeltaRef.current.dy += e.deltaY
+
+ // Schedule a single update per frame
+ if (wheelRafRef.current === null) {
+ wheelRafRef.current = requestAnimationFrame(() => {
+ const { dx, dy } = pendingWheelDeltaRef.current
+ pendingWheelDeltaRef.current = { dx: 0, dy: 0 }
+
+ onWheel({
+ deltaY: dy,
+ deltaX: dx,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.WheelEvent)
+
+ wheelRafRef.current = null
+
+ // nothing else – caching removed
+ })
+ }
+ },
+ [onWheel],
+ )
+
+ // Cleanup any pending RAF on unmount
+ useEffect(() => {
+ return () => {
+ if (wheelRafRef.current !== null) {
+ cancelAnimationFrame(wheelRafRef.current)
+ }
+ }
+ }, [])
+
+ return (
+ <div
+ className="absolute inset-0"
+ onDoubleClick={(ev) =>
+ onDoubleClick?.(ev as unknown as React.MouseEvent)
+ }
+ onKeyDown={(ev) => {
+ if (ev.key === "Enter")
+ handleClick(ev as unknown as React.MouseEvent<HTMLDivElement>)
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerLeave={() => {
+ if (draggingNodeId) onNodeDragEnd()
+ if (isPanningRef.current) onPanEnd()
+ isPanningRef.current = false
+ pointerDownPosRef.current = null
+ pointerMovedRef.current = false
+ }}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onWheel={handleWheel}
+ ref={containerRef}
+ role="application"
+ style={{
+ cursor: draggingNodeId ? "grabbing" : "move",
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ }}
+ >
+ <Application
+ antialias
+ autoDensity
+ backgroundColor={0x0f1419}
+ height={height}
+ resolution={
+ typeof window !== "undefined" ? window.devicePixelRatio : 1
+ }
+ width={width}
+ >
+ {/* Grid background (not affected by world transform) */}
+ <pixiGraphics ref={gridG} draw={() => {}} />
+
+ {/* World container that pans/zooms as a single transform */}
+ <pixiContainer ref={worldContainerRef}>
+ {/* Edges */}
+ <pixiGraphics ref={edgesG} draw={() => {}} />
+
+ {/* Documents */}
+ <pixiGraphics ref={docsG} draw={() => {}} />
+
+ {/* Memories */}
+ <pixiGraphics ref={memsG} draw={() => {}} />
+ </pixiContainer>
+ </Application>
+ </div>
+ )
+ },
+)
+
+GraphWebGLCanvas.displayName = "GraphWebGLCanvas"
diff --git a/packages/ui/memory-graph/hooks/use-graph-data.ts b/packages/ui/memory-graph/hooks/use-graph-data.ts
new file mode 100644
index 00000000..f77f8446
--- /dev/null
+++ b/packages/ui/memory-graph/hooks/use-graph-data.ts
@@ -0,0 +1,299 @@
+"use client"
+
+import {
+ calculateSemanticSimilarity,
+ getConnectionVisualProps,
+ getMagicalConnectionColor,
+} from "@repo/lib/similarity"
+import { useMemo } from "react"
+import { colors, LAYOUT_CONSTANTS } from "../constants"
+import type {
+ DocumentsResponse,
+ DocumentWithMemories,
+ GraphEdge,
+ GraphNode,
+ MemoryEntry,
+ MemoryRelation,
+} from "../types"
+
+export function useGraphData(
+ data: DocumentsResponse | null,
+ selectedSpace: string,
+ nodePositions: Map<string, { x: number; y: number }>,
+ draggingNodeId: string | null,
+) {
+ return useMemo(() => {
+ if (!data?.documents) return { nodes: [], edges: [] }
+
+ const allNodes: GraphNode[] = []
+ const allEdges: GraphEdge[] = []
+
+ // Filter documents that have memories in selected space
+ const filteredDocuments = data.documents
+ .map((doc) => ({
+ ...doc,
+ memoryEntries:
+ selectedSpace === "all"
+ ? doc.memoryEntries
+ : doc.memoryEntries.filter(
+ (memory) =>
+ (memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
+ selectedSpace,
+ ),
+ }))
+ .filter((doc) => doc.memoryEntries.length > 0)
+
+ // Group documents by space for better clustering
+ const documentsBySpace = new Map<string, typeof filteredDocuments>()
+ filteredDocuments.forEach((doc) => {
+ const docSpace =
+ doc.memoryEntries[0]?.spaceContainerTag ??
+ doc.memoryEntries[0]?.spaceId ??
+ "default"
+ if (!documentsBySpace.has(docSpace)) {
+ documentsBySpace.set(docSpace, [])
+ }
+ const spaceDocsArr = documentsBySpace.get(docSpace)
+ if (spaceDocsArr) {
+ spaceDocsArr.push(doc)
+ }
+ })
+
+ // Enhanced Layout with Space Separation
+ const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
+ LAYOUT_CONSTANTS
+
+ /* 1. Build DOCUMENT nodes with space-aware clustering */
+ const documentNodes: GraphNode[] = []
+ let spaceIndex = 0
+
+ documentsBySpace.forEach((spaceDocs) => {
+ const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2
+ const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing
+ const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing
+ const spaceCenterX = centerX + spaceOffsetX
+ const spaceCenterY = centerY + spaceOffsetY
+
+ spaceDocs.forEach((doc, docIndex) => {
+ // Create proper circular layout with concentric rings
+ const docsPerRing = 6 // Start with 6 docs in inner ring
+ let currentRing = 0
+ let docsInCurrentRing = docsPerRing
+ let totalDocsInPreviousRings = 0
+
+ // Find which ring this document belongs to
+ while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) {
+ totalDocsInPreviousRings += docsInCurrentRing
+ currentRing++
+ docsInCurrentRing = docsPerRing + currentRing * 4 // Each ring has more docs
+ }
+
+ // Position within the ring
+ const positionInRing = docIndex - totalDocsInPreviousRings
+ const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2
+
+ // Radius increases significantly with each ring
+ const baseRadius = documentSpacing * 0.8
+ const radius =
+ currentRing === 0
+ ? baseRadius
+ : baseRadius + currentRing * documentSpacing * 1.2
+
+ const defaultX = spaceCenterX + Math.cos(angleInRing) * radius
+ const defaultY = spaceCenterY + Math.sin(angleInRing) * radius
+
+ const customPos = nodePositions.get(doc.id)
+
+ documentNodes.push({
+ id: doc.id,
+ type: "document",
+ x: customPos?.x ?? defaultX,
+ y: customPos?.y ?? defaultY,
+ data: doc,
+ size: 58,
+ color: colors.document.primary,
+ isHovered: false,
+ isDragging: draggingNodeId === doc.id,
+ } satisfies GraphNode)
+ })
+
+ spaceIndex++
+ })
+
+ /* 2. Gentle document collision avoidance with dampening */
+ const minDocDist = LAYOUT_CONSTANTS.minDocDist
+
+ // Reduced iterations and gentler repulsion for smoother movement
+ for (let iter = 0; iter < 2; iter++) {
+ documentNodes.forEach((nodeA) => {
+ documentNodes.forEach((nodeB) => {
+ if (nodeA.id >= nodeB.id) return
+
+ // Only repel documents in the same space
+ const spaceA =
+ (nodeA.data as DocumentWithMemories).memoryEntries[0]
+ ?.spaceContainerTag ??
+ (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
+ "default"
+ const spaceB =
+ (nodeB.data as DocumentWithMemories).memoryEntries[0]
+ ?.spaceContainerTag ??
+ (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
+ "default"
+
+ if (spaceA !== spaceB) return
+
+ const dx = nodeB.x - nodeA.x
+ const dy = nodeB.y - nodeA.y
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1
+
+ if (dist < minDocDist) {
+ // Much gentler push with dampening
+ const push = (minDocDist - dist) / 8
+ const dampening = Math.max(0.1, Math.min(1, dist / minDocDist))
+ const smoothPush = push * dampening * 0.5
+
+ const nx = dx / dist
+ const ny = dy / dist
+ nodeA.x -= nx * smoothPush
+ nodeA.y -= ny * smoothPush
+ nodeB.x += nx * smoothPush
+ nodeB.y += ny * smoothPush
+ }
+ })
+ })
+ }
+
+ allNodes.push(...documentNodes)
+
+ /* 3. Add memories around documents WITH doc-memory connections */
+ documentNodes.forEach((docNode) => {
+ const memoryNodeMap = new Map<string, GraphNode>()
+ const doc = docNode.data as DocumentWithMemories
+
+ doc.memoryEntries.forEach((memory, memIndex) => {
+ const memoryId = `${memory.id}`
+ const customMemPos = nodePositions.get(memoryId)
+
+ const clusterAngle = (memIndex / doc.memoryEntries.length) * Math.PI * 2
+ const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7
+ const distance = clusterRadius * variation
+
+ const seed =
+ memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36)
+ const offsetX = Math.sin(seed) * 0.5 * 40
+ const offsetY = Math.cos(seed) * 0.5 * 40
+
+ const defaultMemX =
+ docNode.x + Math.cos(clusterAngle) * distance + offsetX
+ const defaultMemY =
+ docNode.y + Math.sin(clusterAngle) * distance + offsetY
+
+ if (!memoryNodeMap.has(memoryId)) {
+ const memoryNode: GraphNode = {
+ id: memoryId,
+ type: "memory",
+ x: customMemPos?.x ?? defaultMemX,
+ y: customMemPos?.y ?? defaultMemY,
+ data: memory,
+ size: Math.max(
+ 32,
+ Math.min(48, (memory.memory?.length || 50) * 0.5),
+ ),
+ color: colors.memory.primary,
+ isHovered: false,
+ isDragging: draggingNodeId === memoryId,
+ }
+ memoryNodeMap.set(memoryId, memoryNode)
+ allNodes.push(memoryNode)
+ }
+
+ // Create doc-memory edge with similarity
+ allEdges.push({
+ id: `edge-${docNode.id}-${memory.id}`,
+ source: docNode.id,
+ target: memoryId,
+ similarity: 1,
+ visualProps: getConnectionVisualProps(1),
+ color: colors.connection.memory,
+ edgeType: "doc-memory",
+ })
+ })
+ })
+
+ // Build mapping of memoryId -> nodeId for version chains
+ const memNodeIdMap = new Map<string, string>()
+ allNodes.forEach((n) => {
+ if (n.type === "memory") {
+ memNodeIdMap.set((n.data as MemoryEntry).id, n.id)
+ }
+ })
+
+ // Add version-chain edges (old -> new)
+ data.documents.forEach((doc) => {
+ doc.memoryEntries.forEach((mem: MemoryEntry) => {
+ // Support both new object structure and legacy array/single parent fields
+ let parentRelations: Record<string, MemoryRelation> = {}
+
+ if (mem.memoryRelations && typeof mem.memoryRelations === 'object' && Object.keys(mem.memoryRelations).length > 0) {
+ parentRelations = mem.memoryRelations
+ } else if (mem.parentMemoryId) {
+ parentRelations = {
+ [mem.parentMemoryId]: "updates" as MemoryRelation,
+ }
+ }
+ Object.entries(parentRelations).forEach(([pid, relationType]) => {
+ const fromId = memNodeIdMap.get(pid)
+ const toId = memNodeIdMap.get(mem.id)
+ if (fromId && toId) {
+ allEdges.push({
+ id: `version-${fromId}-${toId}`,
+ source: fromId,
+ target: toId,
+ similarity: 1,
+ visualProps: {
+ opacity: 0.8,
+ thickness: 1,
+ glow: 0,
+ pulseDuration: 3000,
+ },
+ // choose color based on relation type
+ color: colors.relations[relationType] ?? colors.relations.updates,
+ edgeType: "version",
+ relationType: relationType as MemoryRelation,
+ })
+ }
+ })
+ })
+ })
+
+ // Document-to-document similarity edges
+ for (let i = 0; i < filteredDocuments.length; i++) {
+ const docI = filteredDocuments[i]
+ if (!docI) continue
+
+ for (let j = i + 1; j < filteredDocuments.length; j++) {
+ const docJ = filteredDocuments[j]
+ if (!docJ) continue
+
+ const sim = calculateSemanticSimilarity(
+ docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
+ docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
+ )
+ if (sim > 0.725) {
+ allEdges.push({
+ id: `doc-doc-${docI.id}-${docJ.id}`,
+ source: docI.id,
+ target: docJ.id,
+ similarity: sim,
+ visualProps: getConnectionVisualProps(sim),
+ color: getMagicalConnectionColor(sim, 200),
+ edgeType: "doc-doc",
+ })
+ }
+ }
+ }
+
+ return { nodes: allNodes, edges: allEdges }
+ }, [data, selectedSpace, nodePositions, draggingNodeId])
+}
diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts
new file mode 100644
index 00000000..3d599765
--- /dev/null
+++ b/packages/ui/memory-graph/hooks/use-graph-interactions.ts
@@ -0,0 +1,257 @@
+"use client"
+
+import { useCallback, useState } from "react"
+import { GRAPH_SETTINGS } from "../constants"
+import type { GraphNode } from "../types"
+
+export function useGraphInteractions(
+ variant: "console" | "consumer" = "console",
+) {
+ const settings = GRAPH_SETTINGS[variant]
+
+ const [panX, setPanX] = useState(settings.initialPanX)
+ const [panY, setPanY] = useState(settings.initialPanY)
+ const [zoom, setZoom] = useState(settings.initialZoom)
+ const [isPanning, setIsPanning] = useState(false)
+ const [panStart, setPanStart] = useState({ x: 0, y: 0 })
+ const [hoveredNode, setHoveredNode] = useState<string | null>(null)
+ const [selectedNode, setSelectedNode] = useState<string | null>(null)
+ const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
+ const [dragStart, setDragStart] = useState({
+ x: 0,
+ y: 0,
+ nodeX: 0,
+ nodeY: 0,
+ })
+ const [nodePositions, setNodePositions] = useState<
+ Map<string, { x: number; y: number }>
+ >(new Map())
+
+ // Node drag handlers
+ const handleNodeDragStart = useCallback(
+ (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => {
+ const node = nodes?.find((n) => n.id === nodeId)
+ if (!node) return
+
+ setDraggingNodeId(nodeId)
+ setDragStart({
+ x: e.clientX,
+ y: e.clientY,
+ nodeX: node.x,
+ nodeY: node.y,
+ })
+ },
+ [],
+ )
+
+ const handleNodeDragMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (!draggingNodeId) return
+
+ const deltaX = (e.clientX - dragStart.x) / zoom
+ const deltaY = (e.clientY - dragStart.y) / zoom
+
+ const newX = dragStart.nodeX + deltaX
+ const newY = dragStart.nodeY + deltaY
+
+ setNodePositions((prev) =>
+ new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
+ )
+ },
+ [draggingNodeId, dragStart, zoom],
+ )
+
+ const handleNodeDragEnd = useCallback(() => {
+ setDraggingNodeId(null)
+ }, [])
+
+ // Pan handlers
+ const handlePanStart = useCallback(
+ (e: React.MouseEvent) => {
+ setIsPanning(true)
+ setPanStart({ x: e.clientX - panX, y: e.clientY - panY })
+ },
+ [panX, panY],
+ )
+
+ const handlePanMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isPanning || draggingNodeId) return
+
+ const newPanX = e.clientX - panStart.x
+ const newPanY = e.clientY - panStart.y
+ setPanX(newPanX)
+ setPanY(newPanY)
+ },
+ [isPanning, panStart, draggingNodeId],
+ )
+
+ const handlePanEnd = useCallback(() => {
+ setIsPanning(false)
+ }, [])
+
+ // Zoom handlers
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ e.preventDefault()
+ const delta = e.deltaY > 0 ? 0.97 : 1.03
+ setZoom((prev) => Math.max(0.1, Math.min(2, prev * delta)))
+ }, [])
+
+ const zoomIn = useCallback(() => {
+ setZoom((prev) => Math.min(2, prev * 1.1))
+ }, [])
+
+ const zoomOut = useCallback(() => {
+ setZoom((prev) => Math.max(0.1, prev / 1.1))
+ }, [])
+
+ const resetView = useCallback(() => {
+ setPanX(settings.initialPanX)
+ setPanY(settings.initialPanY)
+ setZoom(settings.initialZoom)
+ setNodePositions(new Map())
+ }, [settings])
+
+ // Auto-fit graph to viewport
+ const autoFitToViewport = useCallback(
+ (
+ nodes: GraphNode[],
+ viewportWidth: number,
+ viewportHeight: number,
+ options?: { occludedRightPx?: number; animate?: boolean },
+ ) => {
+ if (nodes.length === 0) return
+
+ // Find the bounds of all nodes
+ let minX = Number.POSITIVE_INFINITY,
+ maxX = Number.NEGATIVE_INFINITY
+ let minY = Number.POSITIVE_INFINITY,
+ maxY = Number.NEGATIVE_INFINITY
+
+ nodes.forEach((node) => {
+ minX = Math.min(minX, node.x - node.size / 2)
+ maxX = Math.max(maxX, node.x + node.size / 2)
+ minY = Math.min(minY, node.y - node.size / 2)
+ maxY = Math.max(maxY, node.y + node.size / 2)
+ })
+
+ // Calculate the center of the content
+ const contentCenterX = (minX + maxX) / 2
+ const contentCenterY = (minY + maxY) / 2
+
+ // Calculate the size of the content
+ const contentWidth = maxX - minX
+ const contentHeight = maxY - minY
+
+ // Add padding (20% on each side)
+ const paddingFactor = 1.4
+ const paddedWidth = contentWidth * paddingFactor
+ const paddedHeight = contentHeight * paddingFactor
+
+ // Account for occluded area on the right (e.g., chat panel)
+ const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0)
+ const availableWidth = Math.max(1, viewportWidth - occludedRightPx)
+
+ // Calculate the zoom needed to fit the content within available width
+ const zoomX = availableWidth / paddedWidth
+ const zoomY = viewportHeight / paddedHeight
+ const newZoom = Math.min(Math.max(0.1, Math.min(zoomX, zoomY)), 2)
+
+ // Calculate pan to center the content within available area
+ const availableCenterX = (availableWidth / 2)
+ const newPanX = availableCenterX - contentCenterX * newZoom
+ const newPanY = viewportHeight / 2 - contentCenterY * newZoom
+
+ // Apply the new view (optional animation)
+ if (options?.animate) {
+ const steps = 8
+ const durationMs = 160 // snappy
+ const intervalMs = Math.max(1, Math.floor(durationMs / steps))
+ const startZoom = zoom
+ const startPanX = panX
+ const startPanY = panY
+ let i = 0
+ const ease = (t: number) => 1 - Math.pow(1 - t, 2) // ease-out quad
+ const timer = setInterval(() => {
+ i++
+ const t = ease(i / steps)
+ setZoom(startZoom + (newZoom - startZoom) * t)
+ setPanX(startPanX + (newPanX - startPanX) * t)
+ setPanY(startPanY + (newPanY - startPanY) * t)
+ if (i >= steps) clearInterval(timer)
+ }, intervalMs)
+ } else {
+ setZoom(newZoom)
+ setPanX(newPanX)
+ setPanY(newPanY)
+ }
+ },
+ [zoom, panX, panY],
+ )
+
+ // Node interaction handlers
+ const handleNodeHover = useCallback((nodeId: string | null) => {
+ setHoveredNode(nodeId)
+ }, [])
+
+ const handleNodeClick = useCallback(
+ (nodeId: string) => {
+ setSelectedNode(selectedNode === nodeId ? null : nodeId)
+ },
+ [selectedNode],
+ )
+
+ const handleDoubleClick = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = e.currentTarget as HTMLCanvasElement
+ const rect = canvas.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ // Calculate new zoom (zoom in by 1.5x)
+ const zoomFactor = 1.5
+ const newZoom = Math.min(2, zoom * zoomFactor)
+
+ // Calculate the world position of the clicked point
+ const worldX = (x - panX) / zoom
+ const worldY = (y - panY) / zoom
+
+ // Calculate new pan to keep the clicked point in the same screen position
+ const newPanX = x - worldX * newZoom
+ const newPanY = y - worldY * newZoom
+
+ setZoom(newZoom)
+ setPanX(newPanX)
+ setPanY(newPanY)
+ },
+ [zoom, panX, panY],
+ )
+
+ return {
+ // State
+ panX,
+ panY,
+ zoom,
+ hoveredNode,
+ selectedNode,
+ draggingNodeId,
+ nodePositions,
+ // Handlers
+ handlePanStart,
+ handlePanMove,
+ handlePanEnd,
+ handleWheel,
+ handleNodeHover,
+ handleNodeClick,
+ handleNodeDragStart,
+ handleNodeDragMove,
+ handleNodeDragEnd,
+ handleDoubleClick,
+ // Controls
+ zoomIn,
+ zoomOut,
+ resetView,
+ autoFitToViewport,
+ setSelectedNode,
+ }
+}
diff --git a/packages/ui/memory-graph/index.ts b/packages/ui/memory-graph/index.ts
new file mode 100644
index 00000000..ddc3bec1
--- /dev/null
+++ b/packages/ui/memory-graph/index.ts
@@ -0,0 +1,19 @@
+// Components
+
+// Types and constants
+export {
+ colors,
+ GRAPH_SETTINGS,
+ LAYOUT_CONSTANTS,
+ POSITIONING,
+} from "./constants";
+export { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas";
+// Hooks
+export { useGraphData } from "./hooks/use-graph-data";
+export { useGraphInteractions } from "./hooks/use-graph-interactions";
+export { Legend } from "./legend";
+export { LoadingIndicator } from "./loading-indicator";
+export { MemoryGraph } from "./memory-graph";
+export { NodeDetailPanel } from "./node-detail-panel";
+export { SpacesDropdown } from "./spaces-dropdown";
+export * from "./types";
diff --git a/packages/ui/memory-graph/legend.tsx b/packages/ui/memory-graph/legend.tsx
new file mode 100644
index 00000000..b65881ff
--- /dev/null
+++ b/packages/ui/memory-graph/legend.tsx
@@ -0,0 +1,323 @@
+"use client"
+
+import { useIsMobile } from "@hooks/use-mobile"
+import { cn } from "@repo/lib/utils"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@repo/ui/components/collapsible"
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
+import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react"
+import { memo, useEffect, useState } from "react"
+import { colors } from "./constants"
+import type { GraphEdge, GraphNode, LegendProps } from "./types"
+
+// Cookie utility functions for legend state
+const setCookie = (name: string, value: string, days = 365) => {
+ if (typeof document === "undefined") return
+ const expires = new Date()
+ expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
+ document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
+}
+
+const getCookie = (name: string): string | null => {
+ if (typeof document === "undefined") return null
+ const nameEQ = `${name}=`
+ const ca = document.cookie.split(";")
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i]
+ if (!c) continue
+ while (c.charAt(0) === " ") c = c.substring(1, c.length)
+ if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
+ }
+ return null
+}
+
+interface ExtendedLegendProps extends LegendProps {
+ id?: string
+ nodes?: GraphNode[]
+ edges?: GraphEdge[]
+ isLoading?: boolean
+ isExperimental?: boolean
+}
+
+export const Legend = memo(function Legend({
+ variant = "console",
+ id,
+ nodes = [],
+ edges = [],
+ isLoading = false,
+ isExperimental = false,
+}: ExtendedLegendProps) {
+ const isMobile = useIsMobile()
+ const [isExpanded, setIsExpanded] = useState(true)
+ const [isInitialized, setIsInitialized] = useState(false)
+
+ const relationData = isExperimental ? [
+ ["updates", colors.relations.updates],
+ ["extends", colors.relations.extends],
+ ["derives", colors.relations.derives],
+ ] : [
+ ["updates", colors.relations.updates],
+ ]
+
+ // Load saved preference on client side
+ useEffect(() => {
+ if (!isInitialized) {
+ const savedState = getCookie("legendCollapsed")
+ if (savedState === "true") {
+ setIsExpanded(false)
+ } else if (savedState === "false") {
+ setIsExpanded(true)
+ } else {
+ // Default: collapsed on mobile, expanded on desktop
+ setIsExpanded(!isMobile)
+ }
+ setIsInitialized(true)
+ }
+ }, [isInitialized, isMobile])
+
+ // Save to cookie when state changes
+ const handleToggleExpanded = (expanded: boolean) => {
+ setIsExpanded(expanded)
+ setCookie("legendCollapsed", expanded ? "false" : "true")
+ }
+
+ // Use explicit classes that Tailwind can detect
+ const getPositioningClasses = () => {
+ if (variant === "console") {
+ // Both desktop and mobile use same positioning for console
+ return "bottom-4 right-4"
+ }
+ if (variant === "consumer") {
+ return isMobile ? "bottom-48 left-4" : "top-18 right-4"
+ }
+ return ""
+ }
+
+ const getMobileSize = () => {
+ if (!isMobile) return ""
+ return isExpanded ? "max-w-xs" : "w-16 h-12"
+ }
+
+ const hexagonClipPath =
+ "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"
+
+ // Calculate stats
+ const memoryCount = nodes.filter((n) => n.type === "memory").length
+ const documentCount = nodes.filter((n) => n.type === "document").length
+
+ return (
+ <div
+ className={cn(
+ "absolute z-10 rounded-xl overflow-hidden w-fit h-fit",
+ getPositioningClasses(),
+ getMobileSize(),
+ isMobile && "hidden md:block"
+ )}
+ id={id}
+ >
+ <Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10">
+ {/* Mobile and Desktop collapsed state */}
+ {!isExpanded && (
+ <CollapsibleTrigger className="w-full h-full p-2 flex items-center justify-center hover:bg-white/5 transition-colors">
+ <div className="flex flex-col items-center gap-1">
+ <div className="text-xs text-slate-300 font-medium">?</div>
+ <ChevronUp className="w-3 h-3 text-slate-400" />
+ </div>
+ </CollapsibleTrigger>
+ )}
+
+ {/* Expanded state */}
+ {isExpanded && (
+ <>
+ {/* Header with toggle */}
+ <div className="flex items-center justify-between px-4 py-3 border-b border-slate-600/50">
+ <div className="text-sm font-medium text-slate-100">Legend</div>
+ <CollapsibleTrigger className="p-1 hover:bg-white/10 rounded">
+ <ChevronDown className="w-4 h-4 text-slate-400" />
+ </CollapsibleTrigger>
+ </div>
+
+ <CollapsibleContent>
+ <div className="text-xs text-slate-200 px-4 py-3 space-y-3">
+ {/* Stats Section */}
+ {!isLoading && (
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Statistics
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <Brain className="w-3 h-3 text-blue-400" />
+ <span className="text-xs">
+ {memoryCount} memories
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <FileText className="w-3 h-3 text-slate-300" />
+ <span className="text-xs">
+ {documentCount} documents
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="w-3 h-3 bg-gradient-to-r from-slate-400 to-blue-400 rounded-full" />
+ <span className="text-xs">
+ {edges.length} connections
+ </span>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Node Types */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Nodes
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div className="w-4 h-3 bg-white/8 border border-white/25 rounded-sm flex-shrink-0" />
+ <span className="text-xs">Document</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ />
+ <span className="text-xs">Memory (latest)</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 opacity-40 flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ />
+ <span className="text-xs">Memory (older)</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Status Indicators */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Status
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-red-500/30 border border-red-500/80 relative flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ >
+ <div className="absolute inset-0 flex items-center justify-center text-red-400 text-xs leading-none">
+ ✕
+ </div>
+ </div>
+ <span className="text-xs">Forgotten</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border-2 border-amber-500 flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ />
+ <span className="text-xs">Expiring soon</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border-2 border-emerald-500 relative flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ >
+ <div className="absolute -top-1 -right-1 w-2 h-2 bg-emerald-500 rounded-full" />
+ </div>
+ <span className="text-xs">New memory</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Connection Types */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Connections
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div className="w-4 h-0 border-t border-slate-400 flex-shrink-0" />
+ <span className="text-xs">Doc → Memory</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="w-4 h-0 border-t-2 border-dashed border-slate-400 flex-shrink-0" />
+ <span className="text-xs">Doc similarity</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Relation Types */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Relations
+ </div>
+ <div className="space-y-1.5">
+ {(isExperimental ? [
+ ["updates", colors.relations.updates],
+ ["extends", colors.relations.extends],
+ ["derives", colors.relations.derives],
+ ] : [
+ ["updates", colors.relations.updates],
+ ]).map(([label, color]) => (
+ <div className="flex items-center gap-2" key={label}>
+ <div
+ className="w-4 h-0 border-t-2 flex-shrink-0"
+ style={{ borderColor: color }}
+ />
+ <span
+ className="text-xs capitalize"
+ style={{ color: color }}
+ >
+ {label}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* Similarity Strength */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Similarity
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div className="w-3 h-3 rounded-full bg-slate-400/20 flex-shrink-0" />
+ <span className="text-xs">Weak</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="w-3 h-3 rounded-full bg-slate-400/60 flex-shrink-0" />
+ <span className="text-xs">Strong</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </CollapsibleContent>
+ </>
+ )}
+ </div>
+ </Collapsible>
+ </div>
+ )
+})
+
+Legend.displayName = "Legend"
diff --git a/packages/ui/memory-graph/loading-indicator.tsx b/packages/ui/memory-graph/loading-indicator.tsx
new file mode 100644
index 00000000..924ec487
--- /dev/null
+++ b/packages/ui/memory-graph/loading-indicator.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import { cn } from "@repo/lib/utils"
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
+import { Sparkles } from "lucide-react"
+import { memo } from "react"
+import type { LoadingIndicatorProps } from "./types"
+
+export const LoadingIndicator = memo<LoadingIndicatorProps>(
+ ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
+ // Use explicit classes that Tailwind can detect
+ const getPositioningClasses = () => {
+ // Both variants use the same positioning for loadingIndicator
+ return "top-20 left-4"
+ }
+
+ if (!isLoading && !isLoadingMore) return null
+
+ return (
+ <div
+ className={cn(
+ "absolute z-10 rounded-xl overflow-hidden",
+ getPositioningClasses(),
+ )}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10 text-slate-200 px-4 py-3">
+ <div className="flex items-center gap-2">
+ <Sparkles className="w-4 h-4 animate-spin text-blue-400" />
+ <span className="text-sm">
+ {isLoading
+ ? "Loading memory graph..."
+ : `Loading more documents... (${totalLoaded})`}
+ </span>
+ </div>
+ </div>
+ </div>
+ )
+ },
+)
+
+LoadingIndicator.displayName = "LoadingIndicator"
diff --git a/packages/ui/memory-graph/memory-graph.tsx b/packages/ui/memory-graph/memory-graph.tsx
new file mode 100644
index 00000000..7a870ce7
--- /dev/null
+++ b/packages/ui/memory-graph/memory-graph.tsx
@@ -0,0 +1,368 @@
+"use client"
+
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
+import { AnimatePresence } from "motion/react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { colors } from "./constants"
+import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas"
+import { useGraphData } from "./hooks/use-graph-data"
+import { useGraphInteractions } from "./hooks/use-graph-interactions"
+import { Legend } from "./legend"
+import { LoadingIndicator } from "./loading-indicator"
+import { NodeDetailPanel } from "./node-detail-panel"
+import { SpacesDropdown } from "./spaces-dropdown"
+
+import type { MemoryGraphProps } from "./types"
+
+export const MemoryGraph = ({
+ children,
+ documents,
+ isLoading,
+ isLoadingMore,
+ error,
+ totalLoaded,
+ hasMore,
+ loadMoreDocuments,
+ showSpacesSelector = true,
+ variant = "console",
+ legendId,
+ highlightDocumentIds = [],
+ highlightsVisible = true,
+ occludedRightPx = 0,
+ autoLoadOnViewport = true,
+ isExperimental = false,
+}: MemoryGraphProps) => {
+ const [selectedSpace, setSelectedSpace] = useState<string>("all")
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
+ const containerRef = useRef<HTMLDivElement>(null)
+
+ // Create data object with dummy pagination to satisfy type requirements
+ const data = useMemo(() => {
+ return documents && documents.length > 0
+ ? {
+ documents,
+ pagination: {
+ currentPage: 1,
+ limit: documents.length,
+ totalItems: documents.length,
+ totalPages: 1,
+ },
+ }
+ : null
+ }, [documents])
+
+ // Graph interactions with variant-specific settings
+ const {
+ panX,
+ panY,
+ zoom,
+ /** hoveredNode currently unused within this component */
+ hoveredNode: _hoveredNode,
+ selectedNode,
+ draggingNodeId,
+ nodePositions,
+ handlePanStart,
+ handlePanMove,
+ handlePanEnd,
+ handleWheel,
+ handleNodeHover,
+ handleNodeClick,
+ handleNodeDragStart,
+ handleNodeDragMove,
+ handleNodeDragEnd,
+ handleDoubleClick,
+ setSelectedNode,
+ autoFitToViewport,
+ } = useGraphInteractions(variant)
+
+ // Graph data
+ const { nodes, edges } = useGraphData(
+ data,
+ selectedSpace,
+ nodePositions,
+ draggingNodeId,
+ )
+
+ // Auto-fit once per unique highlight set to show the full graph for context
+ const lastFittedHighlightKeyRef = useRef<string>("")
+ useEffect(() => {
+ const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : ""
+ if (
+ highlightKey &&
+ highlightKey !== lastFittedHighlightKeyRef.current &&
+ containerSize.width > 0 &&
+ containerSize.height > 0 &&
+ nodes.length > 0
+ ) {
+ autoFitToViewport(nodes, containerSize.width, containerSize.height, { occludedRightPx, animate: true })
+ lastFittedHighlightKeyRef.current = highlightKey
+ }
+ }, [highlightsVisible, highlightDocumentIds, containerSize.width, containerSize.height, nodes.length, occludedRightPx, autoFitToViewport])
+
+ // Auto-fit graph when component mounts or nodes change significantly
+ const hasAutoFittedRef = useRef(false)
+ useEffect(() => {
+ // Only auto-fit once when we have nodes and container size
+ if (
+ !hasAutoFittedRef.current &&
+ nodes.length > 0 &&
+ containerSize.width > 0 &&
+ containerSize.height > 0
+ ) {
+ // For consumer variant, auto-fit to show all content
+ if (variant === "consumer") {
+ autoFitToViewport(nodes, containerSize.width, containerSize.height)
+ hasAutoFittedRef.current = true
+ }
+ }
+ }, [
+ nodes,
+ containerSize.width,
+ containerSize.height,
+ variant,
+ autoFitToViewport,
+ ])
+
+ // Reset auto-fit flag when nodes array becomes empty (switching views)
+ useEffect(() => {
+ if (nodes.length === 0) {
+ hasAutoFittedRef.current = false
+ }
+ }, [nodes.length])
+
+ // Extract unique spaces from memories and calculate counts
+ const { availableSpaces, spaceMemoryCounts } = useMemo(() => {
+ if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }
+
+ const spaceSet = new Set<string>()
+ const counts: Record<string, number> = {}
+
+ data.documents.forEach((doc) => {
+ doc.memoryEntries.forEach((memory) => {
+ const spaceId = memory.spaceContainerTag || memory.spaceId || "default"
+ spaceSet.add(spaceId)
+ counts[spaceId] = (counts[spaceId] || 0) + 1
+ })
+ })
+
+ return {
+ availableSpaces: Array.from(spaceSet).sort(),
+ spaceMemoryCounts: counts,
+ }
+ }, [data])
+
+ // Handle container resize
+ useEffect(() => {
+ const updateSize = () => {
+ if (containerRef.current) {
+ setContainerSize({
+ width: containerRef.current.clientWidth,
+ height: containerRef.current.clientHeight,
+ })
+ }
+ }
+
+ updateSize()
+ window.addEventListener("resize", updateSize)
+ return () => window.removeEventListener("resize", updateSize)
+ }, [])
+
+ // Enhanced node drag start that includes nodes data
+ const handleNodeDragStartWithNodes = useCallback(
+ (nodeId: string, e: React.MouseEvent) => {
+ handleNodeDragStart(nodeId, e, nodes)
+ },
+ [handleNodeDragStart, nodes],
+ )
+
+ // Get selected node data
+ const selectedNodeData = useMemo(() => {
+ if (!selectedNode) return null
+ return nodes.find((n) => n.id === selectedNode) || null
+ }, [selectedNode, nodes])
+
+ // Viewport-based loading: load more when most documents are visible (optional)
+ const checkAndLoadMore = useCallback(() => {
+ if (
+ isLoadingMore ||
+ !hasMore ||
+ !data?.documents ||
+ data.documents.length === 0
+ )
+ return
+
+ // Calculate viewport bounds
+ const viewportBounds = {
+ left: -panX / zoom - 200,
+ right: (-panX + containerSize.width) / zoom + 200,
+ top: -panY / zoom - 200,
+ bottom: (-panY + containerSize.height) / zoom + 200,
+ }
+
+ // Count visible documents
+ const visibleDocuments = data.documents.filter((doc) => {
+ const docNodes = nodes.filter(
+ (node) => node.type === "document" && node.data.id === doc.id,
+ )
+ return docNodes.some(
+ (node) =>
+ node.x >= viewportBounds.left &&
+ node.x <= viewportBounds.right &&
+ node.y >= viewportBounds.top &&
+ node.y <= viewportBounds.bottom,
+ )
+ })
+
+ // If 80% or more of documents are visible, load more
+ const visibilityRatio = visibleDocuments.length / data.documents.length
+ if (visibilityRatio >= 0.8) {
+ loadMoreDocuments()
+ }
+ }, [
+ isLoadingMore,
+ hasMore,
+ data,
+ panX,
+ panY,
+ zoom,
+ containerSize.width,
+ containerSize.height,
+ nodes,
+ loadMoreDocuments,
+ ])
+
+ // Throttled version to avoid excessive checks
+ const lastLoadCheckRef = useRef(0)
+ const throttledCheckAndLoadMore = useCallback(() => {
+ const now = Date.now()
+ if (now - lastLoadCheckRef.current > 1000) {
+ // Check at most once per second
+ lastLoadCheckRef.current = now
+ checkAndLoadMore()
+ }
+ }, [checkAndLoadMore])
+
+ // Monitor viewport changes to trigger loading
+ useEffect(() => {
+ if (!autoLoadOnViewport) return
+ throttledCheckAndLoadMore()
+ }, [throttledCheckAndLoadMore, autoLoadOnViewport])
+
+ // Initial load trigger when graph is first rendered
+ useEffect(() => {
+ if (!autoLoadOnViewport) return
+ if (data?.documents && data.documents.length > 0 && hasMore) {
+ // Start loading more documents after initial render
+ setTimeout(() => {
+ throttledCheckAndLoadMore()
+ }, 500) // Small delay to allow initial layout
+ }
+ }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport])
+
+ if (error) {
+ return (
+ <div
+ className="h-full flex items-center justify-center"
+ style={{ backgroundColor: colors.background.primary }}
+ >
+ <div className="rounded-xl overflow-hidden">
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10 text-slate-200 px-6 py-4">
+ Error loading documents: {error.message}
+ </div>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div
+ className="h-full rounded-xl overflow-hidden"
+ style={{ backgroundColor: colors.background.primary }}
+ >
+ {/* Spaces selector - only shown for console */}
+ {showSpacesSelector && availableSpaces.length > 0 && (
+ <div className="absolute top-4 left-4 z-10">
+ <SpacesDropdown
+ availableSpaces={availableSpaces}
+ onSpaceChange={setSelectedSpace}
+ selectedSpace={selectedSpace}
+ spaceMemoryCounts={spaceMemoryCounts}
+ />
+ </div>
+ )}
+
+ {/* Loading indicator */}
+ <LoadingIndicator
+ isLoading={isLoading}
+ isLoadingMore={isLoadingMore}
+ totalLoaded={totalLoaded}
+ variant={variant}
+ />
+
+ {/* Legend */}
+ <Legend
+ edges={edges}
+ id={legendId}
+ isLoading={isLoading}
+ nodes={nodes}
+ variant={variant}
+ isExperimental={isExperimental}
+ />
+
+ {/* Node detail panel */}
+ <AnimatePresence>
+ {selectedNodeData && (
+ <NodeDetailPanel
+ node={selectedNodeData}
+ onClose={() => setSelectedNode(null)}
+ variant={variant}
+ />
+ )}
+ </AnimatePresence>
+
+ {/* Show welcome screen when no memories exist */}
+ {!isLoading &&
+ (!data || nodes.filter((n) => n.type === "document").length === 0) && (
+ <>{children}</>
+ )}
+
+ {/* Graph container */}
+ <div
+ className="w-full h-full relative overflow-hidden"
+ ref={containerRef}
+ style={{
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ }}
+ >
+ {containerSize.width > 0 && (
+ <GraphCanvas
+ draggingNodeId={draggingNodeId}
+ edges={edges}
+ height={containerSize.height}
+ nodes={nodes}
+ highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
+ onDoubleClick={handleDoubleClick}
+ onNodeClick={handleNodeClick}
+ onNodeDragEnd={handleNodeDragEnd}
+ onNodeDragMove={handleNodeDragMove}
+ onNodeDragStart={handleNodeDragStartWithNodes}
+ onNodeHover={handleNodeHover}
+ onPanEnd={handlePanEnd}
+ onPanMove={handlePanMove}
+ onPanStart={handlePanStart}
+ onWheel={handleWheel}
+ panX={panX}
+ panY={panY}
+ width={containerSize.width}
+ zoom={zoom}
+ />
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/packages/ui/memory-graph/node-detail-panel.tsx b/packages/ui/memory-graph/node-detail-panel.tsx
new file mode 100644
index 00000000..5463c025
--- /dev/null
+++ b/packages/ui/memory-graph/node-detail-panel.tsx
@@ -0,0 +1,264 @@
+"use client"
+
+import { cn } from "@repo/lib/utils"
+import { Badge } from "@repo/ui/components/badge"
+import { Button } from "@repo/ui/components/button"
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
+import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react"
+import { motion } from "motion/react"
+import { memo } from "react"
+import {
+ GoogleDocs,
+ GoogleDrive,
+ GoogleSheets,
+ GoogleSlides,
+ MicrosoftExcel,
+ MicrosoftOneNote,
+ MicrosoftPowerpoint,
+ MicrosoftWord,
+ NotionDoc,
+ OneDrive,
+ PDF,
+} from "../assets/icons"
+import { HeadingH3Bold } from "../text/heading/heading-h3-bold"
+import type {
+ DocumentWithMemories,
+ MemoryEntry,
+ NodeDetailPanelProps,
+} from "./types"
+
+const formatDocumentType = (type: string) => {
+ // Special case for PDF
+ if (type.toLowerCase() === "pdf") return "PDF"
+
+ // Replace underscores with spaces and capitalize each word
+ return type
+ .split("_")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(" ")
+}
+
+const getDocumentIcon = (type: string) => {
+ const iconProps = { className: "w-5 h-5 text-slate-300" }
+
+ switch (type) {
+ case "google_doc":
+ return <GoogleDocs {...iconProps} />
+ case "google_sheet":
+ return <GoogleSheets {...iconProps} />
+ case "google_slide":
+ return <GoogleSlides {...iconProps} />
+ case "google_drive":
+ return <GoogleDrive {...iconProps} />
+ case "notion":
+ case "notion_doc":
+ return <NotionDoc {...iconProps} />
+ case "word":
+ case "microsoft_word":
+ return <MicrosoftWord {...iconProps} />
+ case "excel":
+ case "microsoft_excel":
+ return <MicrosoftExcel {...iconProps} />
+ case "powerpoint":
+ case "microsoft_powerpoint":
+ return <MicrosoftPowerpoint {...iconProps} />
+ case "onenote":
+ case "microsoft_onenote":
+ return <MicrosoftOneNote {...iconProps} />
+ case "onedrive":
+ return <OneDrive {...iconProps} />
+ case "pdf":
+ return <PDF {...iconProps} />
+ default:
+ return <FileText {...iconProps} />
+ }
+}
+
+export const NodeDetailPanel = memo<NodeDetailPanelProps>(
+ ({ node, onClose, variant = "console" }) => {
+ // Use explicit classes that Tailwind can detect
+ const getPositioningClasses = () => {
+ // Both variants use the same positioning for nodeDetail
+ return "top-4 right-4"
+ }
+
+ if (!node) return null
+
+ const isDocument = node.type === "document"
+ const data = node.data
+
+ return (
+ <motion.div
+ animate={{ opacity: 1 }}
+ className={cn(
+ "absolute w-80 rounded-xl overflow-hidden z-20 max-h-[80vh]",
+ getPositioningClasses(),
+ )}
+ exit={{ opacity: 0 }}
+ initial={{ opacity: 0 }}
+ transition={{
+ duration: 0.2,
+ ease: "easeInOut",
+ }}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <motion.div
+ animate={{ opacity: 1 }}
+ className="relative z-10 p-4 overflow-y-auto max-h-[80vh]"
+ initial={{ opacity: 0 }}
+ transition={{ delay: 0.05, duration: 0.15 }}
+ >
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ {isDocument ? (
+ getDocumentIcon((data as DocumentWithMemories).type)
+ ) : (
+ <Brain className="w-5 h-5 text-blue-400" />
+ )}
+ <HeadingH3Bold className="text-slate-100">
+ {isDocument ? "Document" : "Memory"}
+ </HeadingH3Bold>
+ </div>
+ <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
+ <Button
+ className="h-8 w-8 p-0 text-slate-300 hover:text-slate-100"
+ onClick={onClose}
+ size="sm"
+ variant="ghost"
+ >
+ <X className="w-4 h-4" />
+ </Button>
+ </motion.div>
+ </div>
+
+ <div className="space-y-3">
+ {isDocument ? (
+ <>
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Title
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as DocumentWithMemories).title ||
+ "Untitled Document"}
+ </p>
+ </div>
+
+ {(data as DocumentWithMemories).summary && (
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Summary
+ </span>
+ <p className="text-sm text-slate-300 mt-1 line-clamp-3">
+ {(data as DocumentWithMemories).summary}
+ </p>
+ </div>
+ )}
+
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Type
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {formatDocumentType((data as DocumentWithMemories).type)}
+ </p>
+ </div>
+
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Memory Count
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as DocumentWithMemories).memoryEntries.length}{" "}
+ memories
+ </p>
+ </div>
+
+ {((data as DocumentWithMemories).url ||
+ (data as DocumentWithMemories).customId) && (
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ URL
+ </span>
+ <a
+ className="text-sm text-indigo-400 hover:text-indigo-300 mt-1 flex items-center gap-1"
+ href={(() => {
+ const doc = data as DocumentWithMemories
+ if (doc.type === "google_doc" && doc.customId) {
+ return `https://docs.google.com/document/d/${doc.customId}`
+ }
+ if (doc.type === "google_sheet" && doc.customId) {
+ return `https://docs.google.com/spreadsheets/d/${doc.customId}`
+ }
+ if (doc.type === "google_slide" && doc.customId) {
+ return `https://docs.google.com/presentation/d/${doc.customId}`
+ }
+ return doc.url ?? undefined
+ })()}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ <ExternalLink className="w-3 h-3" />
+ View Document
+ </a>
+ </div>
+ )}
+ </>
+ ) : (
+ <>
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Memory
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as MemoryEntry).memory}
+ </p>
+ {(data as MemoryEntry).isForgotten && (
+ <Badge className="mt-2" variant="destructive">
+ Forgotten
+ </Badge>
+ )}
+ {(data as MemoryEntry).forgetAfter && (
+ <p className="text-xs text-slate-400 mt-1">
+ Expires:{" "}
+ {(data as MemoryEntry).forgetAfter ? new Date(
+ (data as MemoryEntry).forgetAfter!,
+ ).toLocaleDateString() : ''}{" "}
+ {'forgetReason' in data && data.forgetReason && `- ${data.forgetReason}`}
+ </p>
+ )}
+ </div>
+
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Space
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as MemoryEntry).spaceId || "Default"}
+ </p>
+ </div>
+ </>
+ )}
+
+ <div className="pt-2 border-t border-slate-700/50">
+ <div className="flex items-center gap-4 text-xs text-slate-400">
+ <span className="flex items-center gap-1">
+ <Calendar className="w-3 h-3" />
+ {new Date(data.createdAt).toLocaleDateString()}
+ </span>
+ <span className="flex items-center gap-1">
+ <Hash className="w-3 h-3" />
+ {node.id}
+ </span>
+ </div>
+ </div>
+ </div>
+ </motion.div>
+ </motion.div>
+ )
+ },
+)
+
+NodeDetailPanel.displayName = "NodeDetailPanel"
diff --git a/packages/ui/memory-graph/spaces-dropdown.tsx b/packages/ui/memory-graph/spaces-dropdown.tsx
new file mode 100644
index 00000000..484b2486
--- /dev/null
+++ b/packages/ui/memory-graph/spaces-dropdown.tsx
@@ -0,0 +1,119 @@
+"use client"
+
+import { cn } from "@repo/lib/utils"
+import { Badge } from "@repo/ui/components/badge"
+import { ChevronDown, Eye } from "lucide-react"
+import { memo, useEffect, useRef, useState } from "react"
+import type { SpacesDropdownProps } from "./types"
+
+export const SpacesDropdown = memo<SpacesDropdownProps>(
+ ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const dropdownRef = useRef<HTMLDivElement>(null)
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false)
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside)
+ return () => document.removeEventListener("mousedown", handleClickOutside)
+ }, [])
+
+ const totalMemories = Object.values(spaceMemoryCounts).reduce(
+ (sum, count) => sum + count,
+ 0,
+ )
+
+ return (
+ <div className="relative" ref={dropdownRef}>
+ <button
+ className={cn(
+ "flex items-center gap-3 px-4 py-3 rounded-xl border-2 border-solid border-transparent",
+ "[background-image:linear-gradient(#1a1f29,_#1a1f29),_linear-gradient(150.262deg,_#A4E8F5_0%,_#267FFA_26%,_#464646_49%,_#747474_70%,_#A4E8F5_100%)]",
+ "[background-origin:border-box] [background-clip:padding-box,_border-box]",
+ "shadow-[inset_0px_2px_1px_rgba(84,84,84,0.15)] backdrop-blur-md",
+ "transition-all duration-200 hover:shadow-[inset_0px_2px_1px_rgba(84,84,84,0.25)]",
+ "cursor-pointer min-w-60",
+ )}
+ onClick={() => setIsOpen(!isOpen)}
+ type="button"
+ >
+ <Eye className="w-4 h-4 text-slate-300" />
+ <div className="flex-1 text-left">
+ <span className="text-sm text-slate-200 font-medium">
+ {selectedSpace === "all"
+ ? "All Spaces"
+ : selectedSpace || "Select space"}
+ </span>
+ <div className="text-xs text-slate-400">
+ {selectedSpace === "all"
+ ? `${totalMemories} total memories`
+ : `${spaceMemoryCounts[selectedSpace] || 0} memories`}
+ </div>
+ </div>
+ <ChevronDown
+ className={cn(
+ "w-4 h-4 text-slate-300 transition-transform duration-200",
+ isOpen && "rotate-180",
+ )}
+ />
+ </button>
+
+ {isOpen && (
+ <div className="absolute top-full left-0 right-0 mt-2 bg-slate-900/95 backdrop-blur-md border border-slate-700/40 rounded-xl shadow-xl z-20 overflow-hidden">
+ <div className="p-1">
+ <button
+ className={cn(
+ "w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
+ selectedSpace === "all"
+ ? "bg-blue-500/20 text-blue-300"
+ : "text-slate-200 hover:bg-slate-700/50",
+ )}
+ onClick={() => {
+ onSpaceChange("all")
+ setIsOpen(false)
+ }}
+ type="button"
+ >
+ <span className="text-sm">All Spaces</span>
+ <Badge className="bg-slate-700/50 text-slate-300 text-xs">
+ {totalMemories}
+ </Badge>
+ </button>
+ {availableSpaces.map((space) => (
+ <button
+ className={cn(
+ "w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
+ selectedSpace === space
+ ? "bg-blue-500/20 text-blue-300"
+ : "text-slate-200 hover:bg-slate-700/50",
+ )}
+ key={space}
+ onClick={() => {
+ onSpaceChange(space)
+ setIsOpen(false)
+ }}
+ type="button"
+ >
+ <span className="text-sm truncate flex-1">{space}</span>
+ <Badge className="bg-slate-700/50 text-slate-300 text-xs ml-2">
+ {spaceMemoryCounts[space] || 0}
+ </Badge>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )
+ },
+)
+
+SpacesDropdown.displayName = "SpacesDropdown"
diff --git a/packages/ui/memory-graph/types.ts b/packages/ui/memory-graph/types.ts
new file mode 100644
index 00000000..7911482e
--- /dev/null
+++ b/packages/ui/memory-graph/types.ts
@@ -0,0 +1,121 @@
+import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
+import type { z } from "zod"
+
+export type DocumentsResponse = z.infer<
+ typeof DocumentsWithMemoriesResponseSchema
+>
+export type DocumentWithMemories = DocumentsResponse["documents"][0]
+export type MemoryEntry = DocumentWithMemories["memoryEntries"][0]
+
+export interface GraphNode {
+ id: string
+ type: "document" | "memory"
+ x: number
+ y: number
+ data: DocumentWithMemories | MemoryEntry
+ size: number
+ color: string
+ isHovered: boolean
+ isDragging: boolean
+}
+
+export type MemoryRelation = "updates" | "extends" | "derives"
+
+export interface GraphEdge {
+ id: string
+ source: string
+ target: string
+ similarity: number
+ visualProps: {
+ opacity: number
+ thickness: number
+ glow: number
+ pulseDuration: number
+ }
+ color: string
+ edgeType: "doc-memory" | "doc-doc" | "version"
+ relationType?: MemoryRelation
+}
+
+export interface SpacesDropdownProps {
+ selectedSpace: string
+ availableSpaces: string[]
+ spaceMemoryCounts: Record<string, number>
+ onSpaceChange: (space: string) => void
+}
+
+export interface NodeDetailPanelProps {
+ node: GraphNode | null
+ onClose: () => void
+ variant?: "console" | "consumer"
+}
+
+export interface GraphCanvasProps {
+ nodes: GraphNode[]
+ edges: GraphEdge[]
+ panX: number
+ panY: number
+ zoom: number
+ width: number
+ height: number
+ onNodeHover: (nodeId: string | null) => void
+ onNodeClick: (nodeId: string) => void
+ onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void
+ onNodeDragMove: (e: React.MouseEvent) => void
+ onNodeDragEnd: () => void
+ onPanStart: (e: React.MouseEvent) => void
+ onPanMove: (e: React.MouseEvent) => void
+ onPanEnd: () => void
+ onWheel: (e: React.WheelEvent) => void
+ onDoubleClick: (e: React.MouseEvent) => void
+ draggingNodeId: string | null
+ // Optional list of document IDs (customId or internal id) to highlight
+ highlightDocumentIds?: string[]
+ isExperimental?: boolean
+}
+
+export interface MemoryGraphProps {
+ children?: React.ReactNode
+ documents: DocumentWithMemories[]
+ isLoading: boolean
+ isLoadingMore: boolean
+ error: Error | null
+ totalLoaded: number
+ hasMore: boolean
+ loadMoreDocuments: () => Promise<void>
+ // App-specific props
+ showSpacesSelector?: boolean // true for console, false for consumer
+ variant?: "console" | "consumer" // for different positioning and styling
+ legendId?: string // Optional ID for the legend component
+ // Optional document highlight list (document custom IDs)
+ highlightDocumentIds?: string[]
+ // Whether highlights are currently visible (e.g., chat open)
+ highlightsVisible?: boolean
+ // Pixels occluded on the right side of the viewport (e.g., chat panel)
+ occludedRightPx?: number
+ // Whether to auto-load more documents based on viewport visibility
+ autoLoadOnViewport?: boolean
+ isExperimental?: boolean
+}
+
+export interface LegendProps {
+ variant?: "console" | "consumer"
+ nodes?: GraphNode[]
+ edges?: GraphEdge[]
+ isLoading?: boolean
+ hoveredNode?: string | null
+}
+
+export interface LoadingIndicatorProps {
+ isLoading: boolean
+ isLoadingMore: boolean
+ totalLoaded: number
+ variant?: "console" | "consumer"
+}
+
+export interface ControlsProps {
+ onZoomIn: () => void
+ onZoomOut: () => void
+ onResetView: () => void
+ variant?: "console" | "consumer"
+}
diff --git a/packages/ui/other/anonymous-auth.tsx b/packages/ui/other/anonymous-auth.tsx
new file mode 100644
index 00000000..009902f9
--- /dev/null
+++ b/packages/ui/other/anonymous-auth.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import { authClient } from "@lib/auth"
+import { useRouter } from "next/navigation"
+import { useEffect } from "react"
+
+export const AnonymousAuth = ({
+ dashboardPath = "/dashboard",
+ loginPath = "/login",
+}) => {
+ const router = useRouter()
+
+ useEffect(() => {
+ const createAnonymousSession = async () => {
+ const session = await authClient.getSession()
+
+ if (!session?.session) {
+ console.debug(
+ "[ANONYMOUS_AUTH] No session found, creating anonymous session...",
+ )
+
+ try {
+ // Create anonymous session
+ console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...")
+ const res = await authClient.signIn.anonymous()
+
+ if (!res.token) {
+ throw new Error("Failed to get anonymous token")
+ }
+
+ // Get the new session
+ console.debug(
+ "[ANONYMOUS_AUTH] Getting new session with anonymous token...",
+ )
+ const newSession = await authClient.getSession()
+
+ console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession)
+
+ if (!newSession?.session || !newSession?.user) {
+ console.error(
+ "[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user",
+ )
+ throw new Error("Failed to create anonymous session")
+ }
+
+ // Get the user's organization
+ console.debug(
+ "[ANONYMOUS_AUTH] Fetching organizations for anonymous user...",
+ )
+ const orgs = await authClient.organization.list()
+
+ console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", {
+ count: orgs?.length || 0,
+ orgs: orgs?.map((o) => ({
+ id: o.id,
+ name: o.name,
+ slug: o.slug,
+ })),
+ })
+
+ const org = orgs?.[0]
+ if (!org) {
+ console.error(
+ "[ANONYMOUS_AUTH] No organization found for anonymous user",
+ )
+ throw new Error("Failed to get organization for anonymous user")
+ }
+
+ // Redirect to the organization dashboard
+ console.debug(
+ `[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`,
+ )
+ router.push(dashboardPath)
+ } catch (error) {
+ console.error(
+ "[ANONYMOUS_AUTH] Anonymous session creation error:",
+ error,
+ )
+ console.error("[ANONYMOUS_AUTH] Error details:", {
+ message: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ })
+ router.push(loginPath)
+ }
+ } else if (session.session) {
+ // Session exists, handle organization routing
+ console.debug(
+ "[ANONYMOUS_AUTH] Session exists, checking organization...",
+ )
+
+ if (!session.session.activeOrganizationId) {
+ console.debug(
+ "[ANONYMOUS_AUTH] No active organization ID, fetching organizations...",
+ )
+ const orgs = await authClient.organization.list()
+
+ console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", {
+ count: orgs?.length || 0,
+ orgs: orgs?.map((o) => ({
+ id: o.id,
+ name: o.name,
+ slug: o.slug,
+ })),
+ })
+
+ if (orgs?.[0]) {
+ console.debug(
+ `[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`,
+ )
+ await authClient.organization.setActive({
+ organizationId: orgs[0].id,
+ })
+ console.debug(
+ `[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`,
+ )
+ router.push(dashboardPath)
+ }
+ } else {
+ console.debug(
+ `[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`,
+ )
+ console.debug(
+ "[ANONYMOUS_AUTH] Fetching full organization details...",
+ )
+ const org = await authClient.organization.getFullOrganization({
+ query: {
+ organizationId: session.session.activeOrganizationId,
+ },
+ })
+
+ console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", {
+ id: org.id,
+ name: org.name,
+ slug: org.slug,
+ })
+
+ console.debug(
+ `[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`,
+ )
+ router.push(dashboardPath)
+ }
+ }
+ }
+
+ createAnonymousSession()
+ }, [router.push])
+
+ // Return null as this component only handles the redirect logic
+ return null
+}
diff --git a/packages/ui/other/glass-effect.tsx b/packages/ui/other/glass-effect.tsx
new file mode 100644
index 00000000..7f735134
--- /dev/null
+++ b/packages/ui/other/glass-effect.tsx
@@ -0,0 +1,18 @@
+interface GlassMenuEffectProps {
+ rounded?: string
+ className?: string
+}
+
+export function GlassMenuEffect({
+ rounded = "rounded-3xl",
+ className = "",
+}: GlassMenuEffectProps) {
+ return (
+ <div className={`absolute inset-0 ${className}`}>
+ {/* Frosted glass effect with translucent border */}
+ <div
+ className={`absolute inset-0 backdrop-blur-md bg-white/5 border border-white/10 ${rounded}`}
+ />
+ </div>
+ )
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 00000000..5785e713
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@repo/ui",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@pixi/react": "^8.0.3",
+ "@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.2",
+ "@radix-ui/react-collapsible": "^1.1.11",
+ "@radix-ui/react-dialog": "^1.1.14",
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-scroll-area": "^1.2.9",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-tabs": "^1.1.12",
+ "@radix-ui/react-toggle": "^1.1.9",
+ "@radix-ui/react-toggle-group": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.7",
+ "class-variance-authority": "^0.7.1",
+ "cmdk": "^1.1.1",
+ "embla-carousel-react": "^8.6.0",
+ "lucide-react": "^0.525.0",
+ "next-themes": "^0.4.6",
+ "pixi.js": "^8.12.0",
+ "recharts": "2.15.4",
+ "sonner": "^2.0.6",
+ "vaul": "^1.1.2"
+ }
+} \ No newline at end of file
diff --git a/packages/ui/pages/login.tsx b/packages/ui/pages/login.tsx
new file mode 100644
index 00000000..8ad3531b
--- /dev/null
+++ b/packages/ui/pages/login.tsx
@@ -0,0 +1,354 @@
+"use client"
+
+import { signIn } from "@lib/auth"
+import { usePostHog } from "@lib/posthog"
+import { LogoFull } from "@repo/ui/assets/Logo"
+import { TextSeparator } from "@repo/ui/components/text-separator"
+import { ExternalAuthButton } from "@ui/button/external-auth"
+import { Button } from "@ui/components/button"
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+} from "@ui/components/carousel"
+import { LabeledInput } from "@ui/input/labeled-input"
+import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium"
+import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
+import { Label1Regular } from "@ui/text/label/label-1-regular"
+import { Title1Bold } from "@ui/text/title/title-1-bold"
+import Autoplay from "embla-carousel-autoplay"
+import Image from "next/image"
+import { useRouter, useSearchParams } from "next/navigation"
+import { useState } from "react"
+
+export function LoginPage({
+ heroText = "The unified memory API for the AI era.",
+ texts = [
+ "Stop building retrieval from scratch.",
+ "Trusted by Open Source, enterprise and developers.",
+ ],
+}) {
+ const [email, setEmail] = useState("")
+ const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [isLoadingEmail, setIsLoadingEmail] = useState(false)
+ const [error, setError] = useState<string | null>(null)
+ const router = useRouter()
+
+ const posthog = usePostHog()
+
+ const params = useSearchParams()
+
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault()
+ setIsLoading(true)
+ setIsLoadingEmail(true)
+ setError(null)
+
+ // Track login attempt
+ posthog.capture("login_attempt", {
+ method: "magic_link",
+ email_domain: email.split("@")[1] || "unknown",
+ })
+
+ try {
+ await signIn.magicLink({
+ callbackURL: window.location.origin,
+ email,
+ })
+ setSubmittedEmail(email)
+
+ // Track successful magic link send
+ posthog.capture("login_magic_link_sent", {
+ email_domain: email.split("@")[1] || "unknown",
+ })
+ } catch (error) {
+ console.error(error)
+
+ // Track login failure
+ posthog.capture("login_failed", {
+ method: "magic_link",
+ error: error instanceof Error ? error.message : "Unknown error",
+ email_domain: email.split("@")[1] || "unknown",
+ })
+
+ setError(
+ error instanceof Error
+ ? error.message
+ : "Failed to send login link. Please try again.",
+ )
+ setIsLoading(false)
+ setIsLoadingEmail(false)
+ return
+ }
+
+ setIsLoading(false)
+ setIsLoadingEmail(false)
+ }
+
+ const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
+ event.preventDefault()
+ setIsLoading(true)
+
+ const formData = new FormData(event.currentTarget)
+ const token = formData.get("token") as string
+ router.push(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(window.location.host)}`,
+ )
+ }
+
+ return (
+ <section className="min-h-screen flex flex-col lg:grid lg:grid-cols-12 items-center justify-center p-4 sm:p-6 md:p-8 lg:px-[5rem] lg:py-[3.125rem] gap-6 lg:gap-[5rem] max-w-[400rem] mx-auto">
+ <Carousel
+ className="hidden lg:block lg:col-span-6"
+ opts={{
+ loop: true,
+ }}
+ plugins={[Autoplay({ delay: 5000 })]}
+ >
+ <CarouselContent>
+ <CarouselItem className="relative">
+ <Image
+ alt="supermemory abstract 2d"
+ height={600}
+ src="/images/login-carousel-1.png"
+ width={600}
+ />
+ <div className="absolute inset-0 flex flex-col justify-end p-6 lg:p-12">
+ <Title1Bold className="text-white mb-2 leading-tight">
+ {texts[0]}
+ </Title1Bold>
+ </div>
+ </CarouselItem>
+ <CarouselItem className="relative">
+ <Image
+ alt="supermemory abstract 3d"
+ height={600}
+ src="/images/login-carousel-2.png"
+ width={600}
+ />
+ <div className="absolute inset-0 flex flex-col justify-end p-6 lg:p-12">
+ <Title1Bold className="text-white mb-2 leading-tight">
+ {texts[1]}
+ </Title1Bold>
+ </div>
+ </CarouselItem>
+ </CarouselContent>
+ </Carousel>
+
+ {submittedEmail ? (
+ <div className="w-full max-w-md lg:max-w-none lg:col-span-5 flex flex-col gap-4 lg:gap-6 min-h-2/3 ">
+ <div className="flex flex-col gap-2 text-center lg:text-left">
+ <Title1Bold className="text-sm-white">Almost there!</Title1Bold>
+ <HeadingH3Medium className="text-sm-gray">
+ Click the magic link we've sent to{" "}
+ <span className="text-sm-white">{submittedEmail}</span>.
+ </HeadingH3Medium>
+ </div>
+
+ <TextSeparator text="OR" />
+
+ <form
+ className="flex flex-col gap-4 lg:gap-6"
+ onSubmit={handleSubmitToken}
+ >
+ <LabeledInput
+ inputPlaceholder="your temporary login code"
+ inputProps={{
+ name: "token",
+ required: true,
+ disabled: isLoading,
+ "aria-invalid": error ? "true" : "false",
+ }}
+ inputType="text"
+ label="Enter code"
+ />
+
+ <Button disabled={isLoading} id="verify-token" type="submit">
+ Verify Token
+ </Button>
+ </form>
+ </div>
+ ) : (
+ <div className="w-full max-w-md lg:max-w-none lg:col-span-5 flex flex-col gap-4 lg:gap-6 min-h-2/3 ">
+ <div className="flex flex-col gap-2 text-center lg:text-left md:mb-12">
+ <Title1Bold className="text-sm-white flex flex-col justify-center md:justify-start md:flex-row items-center gap-3">
+ <span className="block md:hidden">Welcome to </span>{" "}
+ <LogoFull className="h-8" />
+ </Title1Bold>
+ <HeadingH1Medium className="text-sm-silver-chalice">
+ {heroText}
+ </HeadingH1Medium>
+ </div>
+
+ {params.get("error") && (
+ <div className="text-red-500">
+ Error: {params.get("error")}. Please try again!
+ </div>
+ )}
+
+ <form onSubmit={handleSubmit}>
+ <div className="flex flex-col gap-4 lg:gap-6">
+ <LabeledInput
+ error={error}
+ inputPlaceholder="[email protected]"
+ inputProps={{
+ "aria-invalid": error ? "true" : "false",
+ disabled: isLoading,
+ id: "email",
+ onChange: (e) => {
+ setEmail(e.target.value)
+ error && setError(null)
+ },
+ required: true,
+ value: email,
+ }}
+ inputType="email"
+ label="Email"
+ />
+
+ <Button className="w-full" disabled={isLoading} type="submit">
+ {isLoadingEmail
+ ? "Sending login link..."
+ : "Log in to supermemory"}
+ </Button>
+ </div>
+ </form>
+
+ {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
+ !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ||
+ !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
+ <TextSeparator text="OR" />
+ ) : null}
+
+ <div className="flex flex-col sm:flex-row flex-wrap gap-3 lg:gap-4">
+ {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
+ !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
+ <ExternalAuthButton
+ authIcon={
+ <svg
+ className="w-4 h-4 sm:w-5 sm:h-5"
+ fill="none"
+ height="25"
+ viewBox="0 0 24 25"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Google</title>
+ <path
+ d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C16.827 16.5433 14.6115 18.2148 12 18.2148C8.6865 18.2148 6 15.5283 6 12.2148C6 8.90134 8.6865 6.21484 12 6.21484C13.5295 6.21484 14.921 6.79184 15.9805 7.73434L18.809 4.90584C17.023 3.24134 14.634 2.21484 12 2.21484C6.4775 2.21484 2 6.69234 2 12.2148C2 17.7373 6.4775 22.2148 12 22.2148C17.5225 22.2148 22 17.7373 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
+ fill="#FFC107"
+ />
+ <path
+ d="M3.15234 7.56034L6.43784 9.96984C7.32684 7.76884 9.47984 6.21484 11.9993 6.21484C13.5288 6.21484 14.9203 6.79184 15.9798 7.73434L18.8083 4.90584C17.0223 3.24134 14.6333 2.21484 11.9993 2.21484C8.15834 2.21484 4.82734 4.38334 3.15234 7.56034Z"
+ fill="#FF3D00"
+ />
+ <path
+ d="M12.0002 22.2152C14.5832 22.2152 16.9302 21.2267 18.7047 19.6192L15.6097 17.0002C14.5721 17.7897 13.3039 18.2166 12.0002 18.2152C9.39916 18.2152 7.19066 16.5567 6.35866 14.2422L3.09766 16.7547C4.75266 19.9932 8.11366 22.2152 12.0002 22.2152Z"
+ fill="#4CAF50"
+ />
+ <path
+ d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C17.2571 15.3231 16.5467 16.2914 15.608 17.0003L15.6095 16.9993L18.7045 19.6183C18.4855 19.8173 22 17.2148 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
+ fill="#1976D2"
+ />
+ </svg>
+ }
+ authProvider="Google"
+ disabled={isLoading}
+ onClick={() => {
+ if (isLoading) return
+ setIsLoading(true)
+ posthog.capture("login_attempt", {
+ method: "social",
+ provider: "google",
+ })
+ signIn
+ .social({
+ callbackURL: window.location.origin,
+ provider: "google",
+ })
+ .finally(() => {
+ setIsLoading(false)
+ })
+ }}
+ />
+ ) : null}
+ {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
+ !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
+ <ExternalAuthButton
+ authIcon={
+ <svg
+ className="w-4 h-4 sm:w-5 sm:h-5"
+ fill="none"
+ height="25"
+ viewBox="0 0 26 25"
+ width="26"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Github</title>
+ <g clipPath="url(#clip0_2579_3356)">
+ <path
+ clipRule="evenodd"
+ d="M12.9635 0.214844C6.20975 0.214844 0.75 5.71484 0.75 12.5191C0.75 17.9581 4.24825 22.5621 9.10125 24.1916C9.708 24.3141 9.93025 23.9268 9.93025 23.6011C9.93025 23.3158 9.91025 22.3381 9.91025 21.3193C6.51275 22.0528 5.80525 19.8526 5.80525 19.8526C5.25925 18.4266 4.45025 18.0601 4.45025 18.0601C3.33825 17.3063 4.53125 17.3063 4.53125 17.3063C5.76475 17.3878 6.412 18.5693 6.412 18.5693C7.50375 20.4433 9.263 19.9138 9.97075 19.5878C10.0718 18.7933 10.3955 18.2433 10.7393 17.9378C8.0295 17.6526 5.1785 16.5933 5.1785 11.8671C5.1785 10.5226 5.6635 9.42259 6.432 8.56709C6.31075 8.26159 5.886 6.99834 6.5535 5.30759C6.5535 5.30759 7.58475 4.98159 9.91 6.57059C10.9055 6.30126 11.9322 6.16425 12.9635 6.16309C13.9948 6.16309 15.046 6.30584 16.0168 6.57059C18.3423 4.98159 19.3735 5.30759 19.3735 5.30759C20.041 6.99834 19.616 8.26159 19.4948 8.56709C20.2835 9.42259 20.7485 10.5226 20.7485 11.8671C20.7485 16.5933 17.8975 17.6321 15.1675 17.9378C15.6125 18.3248 15.9965 19.0581 15.9965 20.2193C15.9965 21.8693 15.9765 23.1936 15.9765 23.6008C15.9765 23.9268 16.199 24.3141 16.8055 24.1918C21.6585 22.5618 25.1568 17.9581 25.1568 12.5191C25.1768 5.71484 19.697 0.214844 12.9635 0.214844Z"
+ fill="white"
+ fillRule="evenodd"
+ />
+ </g>
+ <defs>
+ <clipPath id="clip0_2579_3356">
+ <rect
+ fill="white"
+ height="24"
+ transform="translate(0.75 0.214844)"
+ width="24.5"
+ />
+ </clipPath>
+ </defs>
+ </svg>
+ }
+ authProvider="Github"
+ disabled={isLoading}
+ onClick={() => {
+ if (isLoading) return
+ setIsLoading(true)
+ posthog.capture("login_attempt", {
+ method: "social",
+ provider: "github",
+ })
+ signIn
+ .social({
+ callbackURL: window.location.origin,
+ provider: "github",
+ })
+ .finally(() => {
+ setIsLoading(false)
+ })
+ }}
+ />
+ ) : null}
+ </div>
+
+ <Label1Regular className="text-sm-gray text-center text-xs sm:text-sm">
+ By continuing, you agree to our{" "}
+ <span className="inline-block">
+ <a
+ className="text-sm-white hover:underline"
+ href="https://supermemory.ai/terms-of-service"
+ >
+ Terms
+ </a>{" "}
+ and{" "}
+ <a
+ className="text-sm-white hover:underline"
+ href="https://supermemory.ai/privacy-policy"
+ >
+ Privacy Policy
+ </a>
+ .
+ </span>
+ </Label1Regular>
+ </div>
+ )}
+ </section>
+ )
+}
diff --git a/packages/ui/text/heading/heading-h1-bold.tsx b/packages/ui/text/heading/heading-h1-bold.tsx
new file mode 100644
index 00000000..c8019a0b
--- /dev/null
+++ b/packages/ui/text/heading/heading-h1-bold.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH1Bold({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h1"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h1"
+ return (
+ <Comp
+ className={cn(
+ "text-sm sm:text-base md:text-lg lg:text-xl font-bold leading-[32px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/heading/heading-h1-medium.tsx b/packages/ui/text/heading/heading-h1-medium.tsx
new file mode 100644
index 00000000..52ddda1c
--- /dev/null
+++ b/packages/ui/text/heading/heading-h1-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH1Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h1"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h1"
+ return (
+ <Comp
+ className={cn(
+ "text-sm sm:text-base md:text-lg lg:text-xl font-medium leading-[32px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/heading/heading-h2-bold.tsx b/packages/ui/text/heading/heading-h2-bold.tsx
new file mode 100644
index 00000000..3da9a399
--- /dev/null
+++ b/packages/ui/text/heading/heading-h2-bold.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH2Bold({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h2"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h2"
+ return (
+ <Comp
+ className={cn(
+ "text-xs sm:text-sm md:text-base lg:text-lg font-bold leading-[30px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/heading/heading-h2-medium.tsx b/packages/ui/text/heading/heading-h2-medium.tsx
new file mode 100644
index 00000000..6324fe86
--- /dev/null
+++ b/packages/ui/text/heading/heading-h2-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH2Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h2"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h2"
+ return (
+ <Comp
+ className={cn(
+ "text-xs sm:text-sm md:text-base lg:text-lg font-medium leading-[30px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/heading/heading-h3-bold.tsx b/packages/ui/text/heading/heading-h3-bold.tsx
new file mode 100644
index 00000000..bb97b323
--- /dev/null
+++ b/packages/ui/text/heading/heading-h3-bold.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH3Bold({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h3"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h3"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.625rem] sm:text-xs md:text-sm lg:text-base font-bold leading-[28px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/heading/heading-h3-medium.tsx b/packages/ui/text/heading/heading-h3-medium.tsx
new file mode 100644
index 00000000..de1a5919
--- /dev/null
+++ b/packages/ui/text/heading/heading-h3-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH3Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h3"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h3"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.625rem] sm:text-xs md:text-sm lg:text-base font-medium leading-[28px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/heading/heading-h4-bold.tsx b/packages/ui/text/heading/heading-h4-bold.tsx
new file mode 100644
index 00000000..271e86db
--- /dev/null
+++ b/packages/ui/text/heading/heading-h4-bold.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH4Bold({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h4"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h4"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.5rem] sm:text-[0.625rem] md:text-xs lg:text-sm font-bold leading-[24px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/heading/heading-h4-medium.tsx b/packages/ui/text/heading/heading-h4-medium.tsx
new file mode 100644
index 00000000..83e1ec66
--- /dev/null
+++ b/packages/ui/text/heading/heading-h4-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function HeadingH4Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h4"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h4"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.5rem] sm:text-[0.625rem] md:text-xs lg:text-sm font-medium leading-[24px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/label/label-1-medium.tsx b/packages/ui/text/label/label-1-medium.tsx
new file mode 100644
index 00000000..a2aa10fe
--- /dev/null
+++ b/packages/ui/text/label/label-1-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Label1Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"p"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "p"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.875rem] md:text-[1rem] font-medium leading-[1.5rem] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/label/label-1-regular.tsx b/packages/ui/text/label/label-1-regular.tsx
new file mode 100644
index 00000000..e740c754
--- /dev/null
+++ b/packages/ui/text/label/label-1-regular.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Label1Regular({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"p"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "p"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.875rem] md:text-[1rem] font-normal leading-[1.5rem] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/label/label-2-medium.tsx b/packages/ui/text/label/label-2-medium.tsx
new file mode 100644
index 00000000..0418ae2b
--- /dev/null
+++ b/packages/ui/text/label/label-2-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Label2Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"p"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "p"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.25rem] sm:text-[0.375rem] md:text-[0.5rem] lg:text-[0.625rem] font-medium leading-[18px] tracking-[-0.4px] text-sm-silver-chalice",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/label/label-2-regular.tsx b/packages/ui/text/label/label-2-regular.tsx
new file mode 100644
index 00000000..f1a5bbe5
--- /dev/null
+++ b/packages/ui/text/label/label-2-regular.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Label2Regular({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"p"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "p"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.25rem] sm:text-[0.375rem] md:text-[0.5rem] lg:text-[0.625rem] font-normal leading-[18px] tracking-[-0.4px] text-sm-silver-chalice",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/label/label-3-medium.tsx b/packages/ui/text/label/label-3-medium.tsx
new file mode 100644
index 00000000..59fdf71b
--- /dev/null
+++ b/packages/ui/text/label/label-3-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Label3Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"p"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "p"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.125rem] sm:text-[0.25rem] md:text-[0.375rem] lg:text-[0.5rem] font-medium leading-[16px] tracking-[-0.2px] text-sm-silver-chalice",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/label/label-3-regular.tsx b/packages/ui/text/label/label-3-regular.tsx
new file mode 100644
index 00000000..147c7e23
--- /dev/null
+++ b/packages/ui/text/label/label-3-regular.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Label3Regular({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"p"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "p"
+ return (
+ <Comp
+ className={cn(
+ "text-[0.125rem] sm:text-[0.25rem] md:text-[0.375rem] lg:text-[0.5rem] font-normal leading-[16px] tracking-[-0.2px] text-sm-silver-chalice",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/title/title-1-bold.tsx b/packages/ui/text/title/title-1-bold.tsx
new file mode 100644
index 00000000..7ae48df6
--- /dev/null
+++ b/packages/ui/text/title/title-1-bold.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Title1Bold({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h1"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h1"
+ return (
+ <Comp
+ className={cn(
+ "text-xl sm:text-2xl md:text-3xl lg:text-4xl font-bold leading-[70px] tracking-[-0.8px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/title/title-1-medium.tsx b/packages/ui/text/title/title-1-medium.tsx
new file mode 100644
index 00000000..da231407
--- /dev/null
+++ b/packages/ui/text/title/title-1-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Title1Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h1"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h1"
+ return (
+ <Comp
+ className={cn(
+ "text-xl sm:text-2xl md:text-3xl lg:text-4xl font-medium leading-[70px] tracking-[-0.8px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/title/title-2-bold.tsx b/packages/ui/text/title/title-2-bold.tsx
new file mode 100644
index 00000000..b32dcdbd
--- /dev/null
+++ b/packages/ui/text/title/title-2-bold.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Title2Bold({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h2"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h2"
+ return (
+ <Comp
+ className={cn(
+ "text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold leading-[48px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/title/title-2-medium.tsx b/packages/ui/text/title/title-2-medium.tsx
new file mode 100644
index 00000000..d931cff7
--- /dev/null
+++ b/packages/ui/text/title/title-2-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Title2Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h2"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h2"
+ return (
+ <Comp
+ className={cn(
+ "text-lg sm:text-xl md:text-2xl lg:text-3xl font-medium leading-[32px] md:leading-[48px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/title/title-3-bold.tsx b/packages/ui/text/title/title-3-bold.tsx
new file mode 100644
index 00000000..6a4a6008
--- /dev/null
+++ b/packages/ui/text/title/title-3-bold.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Title3Bold({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h3"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h3"
+ return (
+ <Comp
+ className={cn(
+ "text-base sm:text-lg md:text-xl lg:text-2xl font-bold leading-[40px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/text/title/title-3-medium.tsx b/packages/ui/text/title/title-3-medium.tsx
new file mode 100644
index 00000000..5e1b13f0
--- /dev/null
+++ b/packages/ui/text/title/title-3-medium.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@lib/utils"
+import { Root } from "@radix-ui/react-slot"
+
+export function Title3Medium({
+ className,
+ asChild,
+ ...props
+}: React.ComponentProps<"h3"> & { asChild?: boolean }) {
+ const Comp = asChild ? Root : "h3"
+ return (
+ <Comp
+ className={cn(
+ "text-base sm:text-lg md:text-xl lg:text-2xl font-medium leading-[40px] tracking-[-0.4px]",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 00000000..66e7706a
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "@total-typescript/tsconfig/bundler/dom/library-monorepo",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"],
+ "@ui/*": ["./*"],
+ "@lib/*": ["../lib/*"],
+ "@hooks/*": ["../hooks/*"]
+ }
+ }
+}
diff --git a/packages/validation/api.ts b/packages/validation/api.ts
new file mode 100644
index 00000000..060c3d69
--- /dev/null
+++ b/packages/validation/api.ts
@@ -0,0 +1,1378 @@
+import { z } from "zod"
+import "zod-openapi/extend"
+import {
+ DocumentSchema,
+ MemoryEntrySchema,
+ OrganizationSettingsSchema,
+ RequestTypeEnum,
+ MetadataSchema as BaseMetadataSchema,
+} from "./schemas"
+
+export const MetadataSchema = BaseMetadataSchema
+
+export const SearchFiltersSchema = z
+ .object({
+ AND: z.array(z.unknown()).optional(),
+ OR: z.array(z.unknown()).optional(),
+ })
+ .or(z.record(z.unknown()))
+
+const exampleMetadata: Record<string, string | number | boolean> = {
+ category: "technology",
+ isPublic: true,
+ readingTime: 5,
+ source: "web",
+ tag_1: "ai",
+ tag_2: "machine-learning",
+} as const
+
+const exampleMemory = {
+ connectionId: "conn_123",
+ containerTags: ["user_123", "project_123"] as const,
+ content: "This is a detailed article about machine learning concepts...",
+ createdAt: new Date().toISOString(),
+ customId: "mem_abc123",
+ id: "acxV5LHMEsG2hMSNb4umbn",
+ metadata: exampleMetadata,
+ ogImage: "https://example.com/image.jpg",
+ raw: "This is a detailed article about machine learning concepts...",
+ source: "web",
+ status: "done",
+ summary:
+ "A comprehensive guide to understanding the basics of machine learning and its applications.",
+ title: "Introduction to Machine Learning",
+ tokenCount: 1000,
+ type: "text",
+ updatedAt: new Date().toISOString(),
+ url: "https://example.com/article",
+} as const
+
+export const MemorySchema = z.object({
+ id: z.string().openapi({
+ description: "Unique identifier of the memory.",
+ example: "acxV5LHMEsG2hMSNb4umbn",
+ }),
+ customId: z.string().nullable().optional().openapi({
+ description:
+ "Optional custom ID of the memory. This could be an ID from your database that will uniquely identify this memory.",
+ example: "mem_abc123",
+ }),
+ connectionId: z.string().nullable().optional().openapi({
+ description:
+ "Optional ID of connection the memory was created from. This is useful for identifying the source of the memory.",
+ example: "conn_123",
+ }),
+ content: z.string().nullable().optional().openapi({
+ description:
+ "The content to extract and process into a memory. This can be a URL to a website, a PDF, an image, or a video. \n\nPlaintext: Any plaintext format\n\nURL: A URL to a website, PDF, image, or video\n\nWe automatically detect the content type from the url's response format.",
+ examples: [
+ "This is a detailed article about machine learning concepts...",
+ "https://example.com/article",
+ "https://youtube.com/watch?v=abc123",
+ "https://example.com/audio.mp3",
+ "https://aws-s3.com/bucket/file.pdf",
+ "https://example.com/image.jpg",
+ ],
+ }),
+ metadata: MetadataSchema.nullable().optional().openapi({
+ description:
+ "Optional metadata for the memory. This is used to store additional information about the memory. You can use this to store any additional information you need about the memory. Metadata can be filtered through. Keys must be strings and are case sensitive. Values can be strings, numbers, or booleans. You cannot nest objects.",
+ example: exampleMetadata,
+ }),
+ source: z.string().nullable().optional().openapi({
+ description: "Source of the memory",
+ example: "web",
+ }),
+ status: DocumentSchema.shape.status.openapi({
+ description: "Status of the memory",
+ example: "done",
+ }),
+ summary: z.string().nullable().optional().openapi({
+ description: "Summary of the memory content",
+ example:
+ "A comprehensive guide to understanding the basics of machine learning and its applications.",
+ }),
+ title: z.string().nullable().optional().openapi({
+ description: "Title of the memory",
+ example: "Introduction to Machine Learning",
+ }),
+ type: DocumentSchema.shape.type.openapi({
+ description: "Type of the memory",
+ example: "text",
+ }),
+ url: z.string().nullable().optional().openapi({
+ description: "URL of the memory",
+ example: "https://example.com/article",
+ }),
+ createdAt: z.string().openapi({
+ description: "Creation timestamp",
+ example: new Date().toISOString(),
+ format: "date-time",
+ }),
+ updatedAt: z.string().openapi({
+ description: "Last update timestamp",
+ example: new Date().toISOString(),
+ format: "date-time",
+ }),
+ containerTags: z
+ .array(z.string())
+ .optional()
+ .readonly()
+ .openapi({
+ description:
+ "Optional tags this memory should be containerized by. This can be an ID for your user, a project ID, or any other identifier you wish to use to group memories.",
+ example: ["user_123", "project_123"] as const,
+ }),
+ chunkCount: z.number().default(0).openapi({
+ description: "Number of chunks in the memory",
+ example: 10,
+ }),
+}).openapi({
+ description: "Memory object",
+ example: exampleMemory,
+})
+
+export const MemoryUpdateSchema = z.object({
+ containerTags: z
+ .array(z.string())
+ .optional()
+ .openapi({
+ description:
+ "Optional tags this memory should be containerized by. This can be an ID for your user, a project ID, or any other identifier you wish to use to group memories.",
+ example: ["user_123", "project_123"],
+ }),
+ content: z.string().optional().openapi({
+ description:
+ "The content to extract and process into a memory. This can be a URL to a website, a PDF, an image, or a video. \n\nPlaintext: Any plaintext format\n\nURL: A URL to a website, PDF, image, or video\n\nWe automatically detect the content type from the url's response format.",
+ example: "This is a detailed article about machine learning concepts...",
+ }),
+ customId: z.string().optional().openapi({
+ description:
+ "Optional custom ID of the memory. This could be an ID from your database that will uniquely identify this memory.",
+ example: "mem_abc123",
+ }),
+ metadata: MetadataSchema.optional().openapi({
+ description:
+ "Optional metadata for the memory. This is used to store additional information about the memory. You can use this to store any additional information you need about the memory. Metadata can be filtered through. Keys must be strings and are case sensitive. Values can be strings, numbers, or booleans. You cannot nest objects.",
+ example: exampleMetadata,
+ }),
+})
+
+export const MemoryAddSchema = MemoryUpdateSchema
+
+export const PaginationSchema = z
+ .object({
+ currentPage: z.number(),
+ limit: z.number().max(1100).default(10),
+ totalItems: z.number(),
+ totalPages: z.number(),
+ })
+ .openapi({
+ description: "Pagination metadata",
+ example: {
+ currentPage: 1,
+ limit: 10,
+ totalItems: 100,
+ totalPages: 10,
+ },
+ })
+
+export const GetMemoryResponseSchema = MemorySchema
+
+export const ListMemoriesResponseSchema = z
+ .object({
+ memories: z.array(
+ MemorySchema.pick({
+ connectionId: true,
+ containerTags: true,
+ createdAt: true,
+ customId: true,
+ id: true,
+ metadata: true,
+ status: true,
+ summary: true,
+ title: true,
+ type: true,
+ updatedAt: true,
+ }),
+ ),
+ pagination: PaginationSchema,
+ })
+ .openapi({
+ description: "List of memories",
+ example: {
+ memories: [
+ {
+ connectionId: exampleMemory.connectionId,
+ containerTags: exampleMemory.containerTags,
+ createdAt: exampleMemory.createdAt,
+ customId: exampleMemory.customId,
+ id: exampleMemory.id,
+ metadata: exampleMemory.metadata,
+ status: exampleMemory.status,
+ summary: exampleMemory.summary,
+ title: exampleMemory.title,
+ type: exampleMemory.type,
+ updatedAt: exampleMemory.updatedAt,
+ },
+ ],
+ pagination: {
+ currentPage: 1,
+ limit: 10,
+ totalItems: 100,
+ totalPages: 10,
+ },
+ },
+ })
+
+export const ListMemoriesQuerySchema = z
+ .object({
+ containerTags: z
+ .array(z.string())
+ .optional()
+ .openapi({
+ description:
+ "Optional tags this memory should be containerized by. This can be an ID for your user, a project ID, or any other identifier you wish to use to group memories.",
+ example: ["user_123", "project_123"],
+ }),
+ // TODO: Improve filter schema
+ filters: z
+ .string()
+ .optional()
+ .openapi({
+ description: "Optional filters to apply to the search",
+ example: JSON.stringify({
+ AND: [
+ {
+ key: "group",
+ negate: false,
+ value: "jira_users",
+ },
+ {
+ filterType: "numeric",
+ key: "timestamp",
+ negate: false,
+ numericOperator: ">",
+ value: "1742745777",
+ },
+ ],
+ }),
+ }),
+ limit: z
+ .string()
+ .regex(/^\d+$/)
+ .or(z.number())
+ .transform(Number)
+ .refine((value) => value <= 1100, {
+ message: "Limit cannot be greater than 1100",
+ })
+ .default("10")
+ .openapi({
+ description: "Number of items per page",
+ example: "10",
+ }),
+ order: z
+ .enum(["asc", "desc"])
+ .default("desc")
+ .openapi({ description: "Sort order", example: "desc" }),
+ page: z
+ .string()
+ .regex(/^\d+$/)
+ .or(z.number())
+ .transform(Number)
+ .default("1")
+ .openapi({ description: "Page number to fetch", example: "1" }),
+ sort: z
+ .enum(["createdAt", "updatedAt"])
+ .default("createdAt")
+ .openapi({ description: "Field to sort by", example: "createdAt" }),
+ })
+ .openapi({
+ description: "Query parameters for listing memories",
+ example: {
+ filters: JSON.stringify({
+ AND: [
+ {
+ key: "group",
+ negate: false,
+ value: "jira_users",
+ },
+ {
+ filterType: "numeric",
+ key: "timestamp",
+ negate: false,
+ numericOperator: ">",
+ value: "1742745777",
+ },
+ ],
+ }),
+ limit: 10,
+ order: "desc",
+ page: 1,
+ sort: "createdAt",
+ },
+ })
+
+export const MemoryResponseSchema = z.object({
+ id: z.string(),
+ status: z.string(),
+})
+
+export const SearchRequestSchema = z.object({
+ categoriesFilter: z
+ .array(z.string())
+ .optional()
+ .openapi({
+ description: "Optional category filters",
+ example: ["technology", "science"],
+ items: {
+ enum: ["technology", "science", "business", "health"],
+ },
+ deprecated: true,
+ }),
+ chunkThreshold: z
+ .number()
+ .optional()
+ .default(0)
+ .refine((v) => v === undefined || (v >= 0 && v <= 1), {
+ message: "chunkThreshold must be between 0 and 1",
+ params: {
+ max: 1,
+ min: 0,
+ },
+ })
+ .transform(Number)
+ .openapi({
+ description:
+ "Threshold / sensitivity for chunk selection. 0 is least sensitive (returns most chunks, more results), 1 is most sensitive (returns lesser chunks, accurate results)",
+ example: 0.5,
+ maximum: 1,
+ minimum: 0,
+ }),
+ containerTags: z
+ .array(z.string())
+ .optional()
+ .openapi({
+ description:
+ "Optional tags this search should be containerized by. This can be an ID for your user, a project ID, or any other identifier you wish to use to filter memories.",
+ example: ["user_123", "project_123"],
+ }),
+ docId: z.string().max(255).optional().openapi({
+ description:
+ "Optional document ID to search within. You can use this to find chunks in a very large document.",
+ example: "doc_xyz789",
+ }),
+ documentThreshold: z
+ .number()
+ .optional()
+ .default(0)
+ .refine((v) => v === undefined || (v >= 0 && v <= 1), {
+ message: "documentThreshold must be between 0 and 1",
+ params: {
+ max: 1,
+ min: 0,
+ },
+ })
+ .transform(Number)
+ .openapi({
+ description:
+ "Threshold / sensitivity for document selection. 0 is least sensitive (returns most documents, more results), 1 is most sensitive (returns lesser documents, accurate results)",
+ example: 0.5,
+ maximum: 1,
+ minimum: 0,
+ }),
+ filters: SearchFiltersSchema.optional().openapi({
+ description: "Optional filters to apply to the search",
+ example: {
+ AND: [
+ {
+ key: "group",
+ negate: false,
+ value: "jira_users",
+ },
+ {
+ filterType: "numeric",
+ key: "timestamp",
+ negate: false,
+ numericOperator: ">",
+ value: "1742745777",
+ },
+ ],
+ },
+ }),
+ includeFullDocs: z.boolean().optional().default(false).openapi({
+ description:
+ "If true, include full document in the response. This is helpful if you want a chatbot to know the full context of the document. ",
+ example: false,
+ }),
+ includeSummary: z.boolean().optional().default(false).openapi({
+ description:
+ "If true, include document summary in the response. This is helpful if you want a chatbot to know the full context of the document. ",
+ example: false,
+ }),
+ limit: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .default(10)
+ .refine((v) => v === undefined || (v > 0 && v <= 100), {
+ message: "limit must be between 1 and 100",
+ params: {
+ max: 100,
+ min: 1,
+ },
+ })
+ .openapi({
+ description: "Maximum number of results to return",
+ example: 10,
+ maximum: 100,
+ minimum: 1,
+ }),
+ onlyMatchingChunks: z.boolean().optional().default(true).openapi({
+ description:
+ "If true, only return matching chunks without context. Normally, we send the previous and next chunk to provide more context for LLMs. If you only want the matching chunk, set this to true.",
+ example: false,
+ }),
+ q: z.string().min(1).openapi({
+ description: "Search query string",
+ example: "machine learning concepts",
+ minLength: 1,
+ }),
+ rerank: z.boolean().optional().default(false).openapi({
+ description:
+ "If true, rerank the results based on the query. This is helpful if you want to ensure the most relevant results are returned.",
+ example: false,
+ }),
+ rewriteQuery: z.boolean().optional().default(false).openapi({
+ description:
+ "If true, rewrites the query to make it easier to find documents. This increases the latency by about 400ms",
+ example: false,
+ }),
+})
+
+export const Searchv4RequestSchema = z.object({
+ containerTag: z.string().optional().openapi({
+ description:
+ "Optional tag this search should be containerized by. This can be an ID for your user, a project ID, or any other identifier you wish to use to filter memories.",
+ example: "user_123",
+ }),
+ threshold: z
+ .number()
+ .optional()
+ .default(0.6)
+ .refine((v) => v === undefined || (v >= 0 && v <= 1), {
+ message: "documentThreshold must be between 0 and 1",
+ params: {
+ max: 1,
+ min: 0,
+ },
+ })
+ .transform(Number)
+ .openapi({
+ description:
+ "Threshold / sensitivity for memories selection. 0 is least sensitive (returns most memories, more results), 1 is most sensitive (returns lesser memories, accurate results)",
+ example: 0.5,
+ maximum: 1,
+ minimum: 0,
+ }),
+ filters: SearchFiltersSchema.optional().openapi({
+ description: "Optional filters to apply to the search",
+ example: {
+ AND: [
+ {
+ key: "group",
+ negate: false,
+ value: "jira_users",
+ },
+ {
+ filterType: "numeric",
+ key: "timestamp",
+ negate: false,
+ numericOperator: ">",
+ value: "1742745777",
+ },
+ ],
+ },
+ }),
+ include: z
+ .object({
+ documents: z.boolean().default(false),
+ summaries: z.boolean().default(false),
+ relatedMemories: z.boolean().default(false),
+ })
+ .optional()
+ .default({
+ documents: false,
+ summaries: false,
+ }),
+ limit: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .default(10)
+ .refine((v) => v === undefined || (v > 0 && v <= 100), {
+ message: "limit must be between 1 and 100",
+ params: {
+ max: 100,
+ min: 1,
+ },
+ })
+ .openapi({
+ description: "Maximum number of results to return",
+ example: 10,
+ maximum: 100,
+ minimum: 1,
+ }),
+ q: z.string().min(1).openapi({
+ description: "Search query string",
+ example: "machine learning concepts",
+ minLength: 1,
+ }),
+ rerank: z.boolean().optional().default(false).openapi({
+ description:
+ "If true, rerank the results based on the query. This is helpful if you want to ensure the most relevant results are returned.",
+ example: false,
+ }),
+ rewriteQuery: z.boolean().optional().default(false).openapi({
+ description:
+ "If true, rewrites the query to make it easier to find documents. This increases the latency by about 400ms",
+ example: false,
+ }),
+})
+
+export const SearchResultSchema = z.object({
+ chunks: z
+ .array(
+ z
+ .object({
+ content: z.string().openapi({
+ description: "Content of the matching chunk",
+ example:
+ "Machine learning is a subset of artificial intelligence...",
+ }),
+ isRelevant: z.boolean().openapi({
+ description: "Whether this chunk is relevant to the query",
+ example: true,
+ }),
+ score: z.number().openapi({
+ description: "Similarity score for this chunk",
+ example: 0.85,
+ maximum: 1,
+ minimum: 0,
+ }),
+ })
+ .openapi({
+ description: "Matching content chunk",
+ example: {
+ content:
+ "Machine learning is a subset of artificial intelligence...",
+ isRelevant: true,
+ score: 0.85,
+ },
+ }),
+ )
+ .openapi({
+ description: "Matching content chunks from the document",
+ example: [
+ {
+ content: "Machine learning is a subset of artificial intelligence...",
+ isRelevant: true,
+ score: 0.85,
+ },
+ ],
+ }),
+ createdAt: z.coerce.date().openapi({
+ description: "Document creation date",
+ example: new Date().toISOString(),
+ format: "date-time",
+ }),
+ documentId: z.string().openapi({
+ description: "ID of the matching document",
+ example: "doc_xyz789",
+ }),
+ metadata: z.record(z.unknown()).nullable().openapi({
+ description: "Document metadata",
+ example: exampleMetadata,
+ }),
+ score: z.number().openapi({
+ description: "Relevance score of the match",
+ example: 0.95,
+ maximum: 1,
+ minimum: 0,
+ }),
+ summary: z.string().nullable().optional().openapi({
+ description: "Document summary",
+ example:
+ "A comprehensive guide to understanding the basics of machine learning and its applications.",
+ }),
+ content: z.string().nullable().optional().openapi({
+ description:
+ "Full document content (only included when includeFullDocs=true)",
+ example:
+ "This is the complete content of the document about machine learning concepts...",
+ }),
+ title: z.string().nullable().openapi({
+ description: "Document title",
+ example: "Introduction to Machine Learning",
+ }),
+ updatedAt: z.coerce.date().openapi({
+ description: "Document last update date",
+ example: new Date().toISOString(),
+ format: "date-time",
+ }),
+ type: z.string().nullable().openapi({
+ description: "Document type",
+ example: "web",
+ }),
+})
+
+export const SearchResponseSchema = z.object({
+ results: z.array(SearchResultSchema),
+ timing: z.number(),
+ total: z.number(),
+})
+
+// V4 Memory Search Schemas
+export const MemorySearchDocumentSchema = z.object({
+ id: z.string().openapi({
+ description: "Document ID",
+ example: "doc_xyz789",
+ }),
+ title: z.string().openapi({
+ description: "Document title",
+ example: "Introduction to Machine Learning",
+ }),
+ type: z.string().openapi({
+ description: "Document type",
+ example: "web",
+ }),
+ metadata: z.record(z.unknown()).nullable().openapi({
+ description: "Document metadata",
+ example: exampleMetadata,
+ }),
+ createdAt: z.coerce.date().openapi({
+ description: "Document creation date",
+ format: "date-time",
+ }),
+ updatedAt: z.coerce.date().openapi({
+ description: "Document last update date",
+ format: "date-time",
+ }),
+})
+
+export const MemorySearchResult = z.object({
+ id: z.string().openapi({
+ description: "Memory entry ID",
+ example: "mem_abc123",
+ }),
+ memory: z.string().openapi({
+ description: "The memory content",
+ example: "John prefers machine learning over traditional programming",
+ }),
+ metadata: z
+ .record(z.unknown())
+ .nullable()
+ .openapi({
+ description: "Memory metadata",
+ example: { source: "conversation", confidence: 0.9 },
+ }),
+ updatedAt: z.coerce.date().openapi({
+ description: "Memory last update date",
+ format: "date-time",
+ }),
+ similarity: z.number().openapi({
+ description: "Similarity score between the query and memory entry",
+ example: 0.89,
+ maximum: 1,
+ minimum: 0,
+ }),
+ version: z.number().nullable().optional().openapi({
+ description: "Version number of this memory entry",
+ example: 3,
+ }),
+ context: z
+ .object({
+ parents: z
+ .array(
+ z.object({
+ relation: z.enum(["updates", "extends", "derives"]).openapi({
+ description: "Relation type between this memory and its parent/child",
+ example: "updates",
+ }),
+ version: z.number().nullable().optional().openapi({
+ description:
+ "Relative version distance from the primary memory (-1 for direct parent, -2 for grand-parent, etc.)",
+ example: -1,
+ }),
+ memory: z.string().openapi({
+ description: "The contextual memory content",
+ example:
+ "Earlier version: Dhravya is working on a patent at Cloudflare.",
+ }),
+ metadata: z.record(z.unknown()).nullable().optional().openapi({
+ description: "Contextual memory metadata",
+ }),
+ updatedAt: z.coerce.date().openapi({
+ description: "Contextual memory last update date",
+ format: "date-time",
+ }),
+ }),
+ )
+ .optional(),
+ children: z
+ .array(
+ z.object({
+ relation: z.enum(["updates", "extends", "derives"]).openapi({
+ description: "Relation type between this memory and its parent/child",
+ example: "extends",
+ }),
+ version: z.number().nullable().optional().openapi({
+ description:
+ "Relative version distance from the primary memory (+1 for direct child, +2 for grand-child, etc.)",
+ example: 1,
+ }),
+ memory: z.string().openapi({
+ description: "The contextual memory content",
+ example:
+ "Later version: Dhravya has filed the patent successfully.",
+ }),
+ metadata: z.record(z.unknown()).nullable().optional().openapi({
+ description: "Contextual memory metadata",
+ }),
+ updatedAt: z.coerce.date().openapi({
+ description: "Contextual memory last update date",
+ format: "date-time",
+ }),
+ }),
+ )
+ .optional(),
+ })
+ .optional()
+ .openapi({
+ description:
+ "Object containing arrays of parent and child contextual memories",
+ }),
+ documents: z.array(MemorySearchDocumentSchema).optional().openapi({
+ description: "Associated documents for this memory entry",
+ }),
+})
+
+export const MemorySearchResponseSchema = z.object({
+ results: z.array(MemorySearchResult).openapi({
+ description: "Array of matching memory entries with similarity scores",
+ }),
+ timing: z.number().openapi({
+ description: "Search execution time in milliseconds",
+ example: 245,
+ }),
+ total: z.number().openapi({
+ description: "Total number of results returned",
+ example: 5,
+ }),
+})
+
+export const ErrorResponseSchema = z.object({
+ details: z.string().optional().openapi({
+ description: "Additional error details",
+ example: "Query must be at least 1 character long",
+ }),
+ error: z.string().openapi({
+ description: "Error message",
+ example: "Invalid request parameters",
+ }),
+})
+
+export type SearchResult = z.infer<typeof SearchResultSchema>
+
+export const SettingsRequestSchema = OrganizationSettingsSchema.omit({
+ id: true,
+ orgId: true,
+ updatedAt: true,
+})
+
+export const ConnectionResponseSchema = z.object({
+ createdAt: z.string().datetime(),
+ documentLimit: z.number().optional(),
+ email: z.string().optional(),
+ expiresAt: z.string().datetime().optional(),
+ id: z.string(),
+ metadata: z.record(z.any()).optional(),
+ provider: z.string(),
+})
+
+export const RequestTypeSchema = RequestTypeEnum
+
+export const HourlyAnalyticsSchema = z.object({
+ count: z.number(),
+ hour: z.union([z.date(), z.string()]),
+})
+
+export const ApiKeyAnalyticsBaseSchema = z.object({
+ count: z.number(),
+ keyId: z.string(),
+ keyName: z.string().nullable(),
+ lastUsed: z.union([z.date(), z.string()]).nullable(),
+})
+
+export const AnalyticsUsageResponseSchema = z.object({
+ byKey: z.array(
+ ApiKeyAnalyticsBaseSchema.extend({
+ avgDuration: z.number().optional(),
+ }),
+ ),
+ hourly: z.array(
+ HourlyAnalyticsSchema.extend({
+ avgDuration: z.number().optional(),
+ }),
+ ),
+ pagination: PaginationSchema,
+ totalMemories: z.number(),
+ usage: z.array(
+ z.object({
+ avgDuration: z.number().optional(),
+ count: z.number(),
+ lastUsed: z.union([z.date(), z.string()]).nullable(),
+ type: RequestTypeSchema,
+ }),
+ ),
+})
+
+export const AnalyticsErrorResponseSchema = z.object({
+ byKey: z.array(
+ ApiKeyAnalyticsBaseSchema.extend({
+ errorCount: z.number(),
+ errorRate: z.number(),
+ }),
+ ),
+ errors: z.array(
+ z.object({
+ count: z.number(),
+ percentage: z.number(),
+ statusCode: z.number(),
+ type: RequestTypeSchema,
+ }),
+ ),
+ hourly: z.array(
+ HourlyAnalyticsSchema.extend({
+ errorCount: z.number(),
+ errorRate: z.number(),
+ }),
+ ),
+ pagination: PaginationSchema,
+ summary: z.array(
+ z.object({
+ errorRate: z.number(),
+ lastRequest: z.union([z.date(), z.string()]).nullable(),
+ successRate: z.number(),
+ totalRequests: z.number(),
+ type: RequestTypeSchema,
+ }),
+ ),
+})
+
+export const AnalyticsLogSchema = z.object({
+ createdAt: z.date(),
+ duration: z.number(),
+ id: z.string(),
+ ingestion: z
+ .object({
+ createdAt: z.date(),
+ metadata: z.record(z.unknown()),
+ status: z.string(),
+ summary: z.string(),
+ title: z.string(),
+ url: z.string(),
+ })
+ .optional(),
+ input: z.record(z.unknown()),
+ output: z.discriminatedUnion("type", [
+ z.object({
+ response: MemoryResponseSchema,
+ type: z.literal("add"),
+ }),
+ z.object({
+ response: SearchResponseSchema,
+ type: z.literal("search"),
+ }),
+ z.object({
+ response: z.object({
+ success: z.boolean(),
+ }),
+ type: z.literal("delete"),
+ }),
+ z.object({
+ response: MemoryResponseSchema,
+ type: z.literal("update"),
+ }),
+ ]),
+ statusCode: z.number(),
+ type: RequestTypeSchema,
+})
+
+export const AnalyticsLogsResponseSchema = z.object({
+ logs: z.array(z.unknown()),
+ pagination: PaginationSchema,
+})
+
+export const AnalyticsChatResponseSchema = z.object({
+ analytics: z.object({
+ apiUsage: z.object({
+ current: z.number(),
+ limit: z.number(),
+ }),
+ latency: z.object({
+ current: z.number(),
+ trend: z.array(z.number()),
+ unit: z.literal("ms"),
+ }),
+ usage: z.object({
+ currentDay: z.enum(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]),
+ tokensByDay: z.object({
+ Fri: z.number(),
+ Mon: z.number(),
+ Sat: z.number(),
+ Sun: z.number(),
+ Thu: z.number(),
+ Tue: z.number(),
+ Wed: z.number(),
+ }),
+ }),
+ }),
+ overview: z.object({
+ "7d": z.object({
+ amountSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensProcessed: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensSent: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ totalTokensSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ }),
+ "30d": z.object({
+ amountSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensProcessed: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensSent: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ totalTokensSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ }),
+ "90d": z.object({
+ amountSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensProcessed: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensSent: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ totalTokensSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ }),
+ lifetime: z.object({
+ amountSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensProcessed: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ tokensSent: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ totalTokensSaved: z.object({
+ current: z.number(),
+ previousPeriod: z.number(),
+ }),
+ }),
+ }),
+})
+
+export const AnalyticsMemoryResponseSchema = z.object({
+ connectionsGrowth: z.number(),
+ memoriesGrowth: z.number(),
+ searchGrowth: z.number(),
+ searchQueries: z.number(),
+ tokensGrowth: z.number(),
+ tokensProcessed: z.number(),
+ totalConnections: z.number(),
+ totalMemories: z.number(),
+})
+
+export const MemoryEntryAPISchema = MemoryEntrySchema
+ .extend({
+ sourceAddedAt: z.date().nullable(), // From join relationship
+ sourceRelevanceScore: z.number().nullable(), // From join relationship
+ sourceMetadata: z.record(z.unknown()).nullable(), // From join relationship
+ spaceContainerTag: z.string().nullable(), // From join relationship
+ })
+ .openapi({
+ description: "Memory entry with source relationship data",
+ })
+
+// Extended document schema with memory entries
+export const DocumentWithMemoriesSchema = z.object({
+ id: DocumentSchema.shape.id,
+ customId: DocumentSchema.shape.customId,
+ contentHash: DocumentSchema.shape.contentHash,
+ orgId: DocumentSchema.shape.orgId,
+ userId: DocumentSchema.shape.userId,
+ connectionId: DocumentSchema.shape.connectionId,
+ title: DocumentSchema.shape.title,
+ content: DocumentSchema.shape.content,
+ summary: DocumentSchema.shape.summary,
+ url: DocumentSchema.shape.url,
+ source: DocumentSchema.shape.source,
+ type: DocumentSchema.shape.type,
+ status: DocumentSchema.shape.status,
+ metadata: DocumentSchema.shape.metadata,
+ processingMetadata: DocumentSchema.shape.processingMetadata,
+ raw: DocumentSchema.shape.raw,
+ tokenCount: DocumentSchema.shape.tokenCount,
+ wordCount: DocumentSchema.shape.wordCount,
+ chunkCount: DocumentSchema.shape.chunkCount,
+ averageChunkSize: DocumentSchema.shape.averageChunkSize,
+ summaryEmbedding: DocumentSchema.shape.summaryEmbedding,
+ summaryEmbeddingModel: DocumentSchema.shape.summaryEmbeddingModel,
+ createdAt: DocumentSchema.shape.createdAt,
+ updatedAt: DocumentSchema.shape.updatedAt,
+ memoryEntries: z.array(MemoryEntryAPISchema),
+}).openapi({
+ description: "Document with associated memory entries",
+})
+
+export const DocumentsWithMemoriesResponseSchema = z
+ .object({
+ documents: z.array(DocumentWithMemoriesSchema),
+ pagination: PaginationSchema,
+ })
+ .openapi({
+ description: "List of documents with their memory entries",
+ })
+
+export const DocumentsWithMemoriesQuerySchema = z
+ .object({
+ page: z.number().default(1).openapi({
+ description: "Page number to fetch",
+ example: 1,
+ }),
+ limit: z.number().default(10).openapi({
+ description: "Number of items per page",
+ example: 10,
+ }),
+ sort: z.enum(["createdAt", "updatedAt"]).default("createdAt").openapi({
+ description: "Field to sort by",
+ example: "createdAt",
+ }),
+ order: z.enum(["asc", "desc"]).default("desc").openapi({
+ description: "Sort order",
+ example: "desc",
+ }),
+ containerTags: z
+ .array(z.string())
+ .optional()
+ .openapi({
+ description: "Optional container tags to filter documents by",
+ example: ["sm_project_default"],
+ }),
+ })
+ .openapi({
+ description: "Query parameters for listing documents with memory entries",
+ })
+
+export const MigrateMCPRequestSchema = z
+ .object({
+ userId: z.string().openapi({
+ description: "User ID to migrate documents for",
+ example: "user_123",
+ }),
+ projectId: z.string().default("default").openapi({
+ description: "Project ID to migrate documents to",
+ example: "school",
+ }),
+ })
+ .openapi({
+ description: "Request body for migrating MCP documents",
+ })
+
+export const MigrateMCPResponseSchema = z
+ .object({
+ success: z.boolean().openapi({
+ description: "Whether the migration was successful",
+ example: true,
+ }),
+ migratedCount: z.number().openapi({
+ description: "Number of documents migrated",
+ example: 5,
+ }),
+ message: z.string().openapi({
+ description: "Status message",
+ example: "Successfully migrated 5 documents",
+ }),
+ documentIds: z
+ .array(z.string())
+ .optional()
+ .openapi({
+ description: "IDs of migrated documents",
+ example: ["doc_123", "doc_456", "doc_789"],
+ }),
+ })
+ .openapi({
+ description: "Response for MCP document migration",
+ })
+
+// Processing documents schema
+export const ProcessingDocumentsResponseSchema = z
+ .object({
+ documents: z.array(
+ MemorySchema.pick({
+ id: true,
+ customId: true,
+ title: true,
+ type: true,
+ status: true,
+ createdAt: true,
+ updatedAt: true,
+ metadata: true,
+ containerTags: true,
+ }),
+ ),
+ totalCount: z.number().openapi({
+ description: "Total number of processing documents",
+ example: 5,
+ }),
+ })
+ .openapi({
+ description: "List of documents currently being processed",
+ example: {
+ documents: [
+ {
+ id: "doc_123",
+ customId: "custom_123",
+ title: "My Document",
+ type: "text",
+ status: "extracting",
+ createdAt: "2024-12-27T12:00:00Z",
+ updatedAt: "2024-12-27T12:01:00Z",
+ metadata: {},
+ containerTags: ["sm_project_default"],
+ },
+ ],
+ totalCount: 5,
+ },
+ })
+
+// Project schemas
+export const ProjectSchema = z
+ .object({
+ id: z.string().openapi({
+ description: "Unique identifier of the project",
+ example: "proj_abc123",
+ }),
+ name: z.string().openapi({
+ description: "Display name of the project",
+ example: "My Awesome Project",
+ }),
+ containerTag: z.string().openapi({
+ description:
+ "Container tag for organizing memories (format: sm_project_{name})",
+ example: "sm_project_my_awesome_project",
+ }),
+ createdAt: z.string().openapi({
+ description: "Creation timestamp",
+ example: new Date().toISOString(),
+ format: "date-time",
+ }),
+ updatedAt: z.string().openapi({
+ description: "Last update timestamp",
+ example: new Date().toISOString(),
+ format: "date-time",
+ }),
+ isExperimental: z.boolean().openapi({
+ description: "Whether the project (space) is in experimental mode",
+ example: false,
+ }),
+ documentCount: z.number().optional().openapi({
+ description: "Number of documents in this project",
+ example: 42,
+ }),
+ })
+ .openapi({
+ description: "Project object for organizing memories",
+ })
+
+export const CreateProjectSchema = z
+ .object({
+ name: z.string().min(1).max(100).openapi({
+ description: "Name for the project",
+ example: "My Awesome Project",
+ minLength: 1,
+ maxLength: 100,
+ }),
+ })
+ .openapi({
+ description: "Request body for creating a new project",
+ })
+
+export const ListProjectsResponseSchema = z
+ .object({
+ projects: z.array(ProjectSchema).openapi({
+ description: "List of projects",
+ }),
+ })
+ .openapi({
+ description: "Response containing list of projects",
+ })
+
+export const DeleteProjectSchema = z
+ .object({
+ action: z.enum(["move", "delete"]).openapi({
+ description: "Action to perform on documents in the project",
+ example: "move",
+ }),
+ targetProjectId: z.string().optional().openapi({
+ description: "Target project ID when action is 'move'",
+ example: "proj_xyz789",
+ }),
+ })
+ .refine(
+ (data) => {
+ // If action is "move", targetProjectId is required
+ if (data.action === "move") {
+ return !!data.targetProjectId
+ }
+ return true
+ },
+ {
+ message: "targetProjectId is required when action is 'move'",
+ path: ["targetProjectId"],
+ },
+ )
+ .openapi({
+ description: "Request body for deleting a project",
+ })
+
+export const DeleteProjectResponseSchema = z
+ .object({
+ success: z.boolean().openapi({
+ description: "Whether the deletion was successful",
+ example: true,
+ }),
+ message: z.string().openapi({
+ description: "Status message",
+ example: "Project deleted successfully",
+ }),
+ documentsAffected: z.number().openapi({
+ description: "Number of documents affected by the operation",
+ example: 10,
+ }),
+ memoriesAffected: z.number().openapi({
+ description: "Number of memories affected by the operation",
+ example: 5,
+ }),
+ })
+ .openapi({
+ description: "Response for project deletion",
+ })
+
+// Bulk delete schema - supports both IDs and container tags
+export const BulkDeleteMemoriesSchema = z
+ .object({
+ ids: z
+ .array(z.string())
+ .min(1)
+ .max(100)
+ .optional()
+ .openapi({
+ description: "Array of memory IDs to delete (max 100 at once)",
+ example: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"],
+ }),
+ containerTags: z
+ .array(z.string())
+ .min(1)
+ .optional()
+ .openapi({
+ description:
+ "Array of container tags - all memories in these containers will be deleted",
+ example: ["user_123", "project_123"],
+ }),
+ })
+ .refine(
+ (data) => {
+ // At least one of ids or containerTags must be provided
+ return !!data.ids?.length || !!data.containerTags?.length
+ },
+ {
+ message: "Either 'ids' or 'containerTags' must be provided",
+ },
+ )
+ .openapi({
+ description:
+ "Request body for bulk deleting memories by IDs or container tags",
+ example: {
+ ids: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"],
+ },
+ })
+
+export const BulkDeleteMemoriesResponseSchema = z
+ .object({
+ success: z.boolean().openapi({
+ description: "Whether the bulk deletion was successful",
+ example: true,
+ }),
+ deletedCount: z.number().openapi({
+ description: "Number of memories successfully deleted",
+ example: 2,
+ }),
+ errors: z
+ .array(
+ z.object({
+ id: z.string(),
+ error: z.string(),
+ }),
+ )
+ .optional()
+ .openapi({
+ description:
+ "Array of errors for memories that couldn't be deleted (only applicable when deleting by IDs)",
+ }),
+ containerTags: z
+ .array(z.string())
+ .optional()
+ .openapi({
+ description:
+ "Container tags that were processed (only applicable when deleting by container tags)",
+ example: ["user_123", "project_123"],
+ }),
+ })
+ .openapi({
+ description: "Response for bulk memory deletion",
+ })
diff --git a/packages/validation/connection.ts b/packages/validation/connection.ts
new file mode 100644
index 00000000..e7bc8352
--- /dev/null
+++ b/packages/validation/connection.ts
@@ -0,0 +1,191 @@
+import { z } from "zod"
+import { ConnectionProviderEnum } from "./schemas"
+
+export const providers = ConnectionProviderEnum
+export type Provider = z.infer<typeof providers>
+
+const BaseMetadataSchema = <T extends z.ZodTypeAny>(provider: T) =>
+ z.object({
+ provider,
+ })
+
+export const NotionMetadataSchema = BaseMetadataSchema(
+ z.literal("notion"),
+).extend({
+ email: z.string().email().optional(),
+ ownerId: z.string(),
+ workspaceIcon: z.string().optional(),
+ workspaceId: z.string(),
+ workspaceName: z.string(),
+})
+export type NotionMetadata = z.infer<typeof NotionMetadataSchema>
+
+export const GoogleDriveMetadataSchema = BaseMetadataSchema(
+ z.literal("google-drive"),
+).extend({
+ pageToken: z.number(),
+ webhookChannelId: z.string().optional(),
+ webhookExpiration: z.number().optional(),
+ webhookResourceId: z.string().optional(),
+})
+export type GoogleDriveMetadata = z.infer<typeof GoogleDriveMetadataSchema>
+
+export const OneDriveMetadataSchema = BaseMetadataSchema(
+ z.literal("onedrive"),
+).extend({
+ deltaLink: z.string().optional(),
+ lastRenewalCheck: z.number().optional(),
+ webhookClientState: z.string().optional(),
+ webhookExpiration: z.number().optional(),
+ webhookSubscriptionId: z.string().optional(),
+})
+export type OneDriveMetadata = z.infer<typeof OneDriveMetadataSchema>
+
+export const ConnectionMetadataSchema = z.discriminatedUnion("provider", [
+ NotionMetadataSchema,
+ GoogleDriveMetadataSchema,
+ OneDriveMetadataSchema,
+])
+export type ConnectionMetadata<T extends Provider> = T extends "notion"
+ ? NotionMetadata
+ : T extends "google-drive"
+ ? GoogleDriveMetadata
+ : T extends "onedrive"
+ ? OneDriveMetadata
+ : never
+
+export function isNotionMetadata(
+ metadata: unknown,
+): metadata is NotionMetadata {
+ return NotionMetadataSchema.safeParse(metadata).success
+}
+
+export function isGoogleDriveMetadata(
+ metadata: unknown,
+): metadata is GoogleDriveMetadata {
+ return GoogleDriveMetadataSchema.safeParse(metadata).success
+}
+
+export function isOneDriveMetadata(
+ metadata: unknown,
+): metadata is OneDriveMetadata {
+ return OneDriveMetadataSchema.safeParse(metadata).success
+}
+
+export const ConnectionStateSchema = z.object({
+ createdAt: z.number(),
+ org: z.string(),
+ provider: providers,
+ userId: z.string(),
+})
+export type ConnectionState = z.infer<typeof ConnectionStateSchema>
+
+export const TokenDataSchema = z.object({
+ // Only used for Notion connections since they don't support refresh tokens
+ accessToken: z.string().optional(),
+ createdAt: z.number(),
+ documentLimit: z.number().optional(),
+ email: z.string().email().optional(),
+ expiresAt: z.number().optional(),
+ metadata: ConnectionMetadataSchema.optional(),
+ refreshToken: z.string().optional(),
+ userId: z.string().optional(),
+})
+export type TokenData = z.infer<typeof TokenDataSchema>
+
+export const NotionTokenResponseSchema = z.object({
+ access_token: z.string(),
+ bot_id: z.string().optional(),
+ duplicated_template_id: z.string().nullable().optional(),
+ owner: z.union([
+ z.object({
+ type: z.literal("user"),
+ user: z.object({
+ avatar_url: z.string().url().optional(),
+ id: z.string(),
+ name: z.string().optional(),
+ object: z.literal("user"),
+ person: z
+ .object({
+ email: z.string().email().optional(),
+ })
+ .optional(),
+ type: z.literal("person"),
+ }),
+ }),
+ z.object({
+ type: z.literal("workspace"),
+ workspace: z.literal(true),
+ }),
+ ]),
+ request_id: z.string().optional(),
+ token_type: z.literal("bearer"),
+ workspace_icon: z.string().optional(),
+ workspace_id: z.string(),
+ workspace_name: z.string(),
+})
+export type NotionTokenResponse = z.infer<typeof NotionTokenResponseSchema>
+
+export const GoogleDriveTokenResponseSchema = z.object({
+ access_token: z.string(),
+ expires_in: z.number(),
+ refresh_token: z.string().optional(),
+ scope: z.string(),
+ token_type: z.literal("Bearer"),
+})
+export type GoogleDriveTokenResponse = z.infer<
+ typeof GoogleDriveTokenResponseSchema
+>
+
+export const OneDriveTokenResponseSchema = z.object({
+ access_token: z.string(),
+ expires_in: z.number(),
+ refresh_token: z.string().optional(),
+ scope: z.string(),
+ token_type: z.literal("Bearer"),
+})
+export type OneDriveTokenResponse = z.infer<typeof OneDriveTokenResponseSchema>
+
+export const NotionConfigSchema = z.object({
+ clientId: z.string(),
+ clientSecret: z.string(),
+ endpoints: z.object({
+ authorize: z.string().url(),
+ token: z.string().url(),
+ }),
+ scopes: z.array(z.string()),
+})
+export type NotionConfig = z.infer<typeof NotionConfigSchema>
+
+export const ConnectionQuerySchema = z.object({
+ id: z.string(),
+ redirectUrl: z.string().optional(),
+})
+
+export const GoogleDrivePageTokenResponseSchema = z.object({
+ startPageToken: z.union([z.string(), z.number()]),
+})
+
+export const GoogleDriveWatchResponseSchema = z.object({
+ expiration: z.string(),
+ id: z.string(),
+ resourceId: z.string(),
+})
+
+export const OneDriveSubscriptionResponseSchema = z.object({
+ changeType: z.string(),
+ clientState: z.string(),
+ expirationDateTime: z.string(),
+ id: z.string(),
+ notificationUrl: z.string(),
+ resource: z.string(),
+})
+
+export const GoogleUserInfoResponseSchema = z.object({
+ email: z.string().email(),
+})
+
+export const MicrosoftUserInfoResponseSchema = z.object({
+ mail: z.string().optional(),
+ userPrincipalName: z.string().optional(),
+})
diff --git a/packages/validation/package.json b/packages/validation/package.json
new file mode 100644
index 00000000..e217993d
--- /dev/null
+++ b/packages/validation/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@repo/validation",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module"
+} \ No newline at end of file
diff --git a/packages/validation/schemas.ts b/packages/validation/schemas.ts
new file mode 100644
index 00000000..1cd2701c
--- /dev/null
+++ b/packages/validation/schemas.ts
@@ -0,0 +1,380 @@
+import { z } from "zod"
+
+export const MetadataSchema = z.record(
+ z.union([z.string(), z.number(), z.boolean()]),
+)
+export type Metadata = z.infer<typeof MetadataSchema>
+
+export const VisibilityEnum = z.enum(["public", "private", "unlisted"])
+export type Visibility = z.infer<typeof VisibilityEnum>
+
+export const DocumentTypeEnum = z.enum([
+ "text",
+ "pdf",
+ "tweet",
+ "google_doc",
+ "google_slide",
+ "google_sheet",
+ "image",
+ "video",
+ "notion_doc",
+ "webpage",
+ "onedrive",
+])
+export type DocumentType = z.infer<typeof DocumentTypeEnum>
+
+export const DocumentStatusEnum = z.enum([
+ "unknown",
+ "queued",
+ "extracting",
+ "chunking",
+ "embedding",
+ "indexing",
+ "done",
+ "failed",
+])
+export type DocumentStatus = z.infer<typeof DocumentStatusEnum>
+
+export const ProcessingStepSchema = z.object({
+ name: z.string(),
+ startTime: z.number(),
+ endTime: z.number().optional(),
+ status: z.enum(["completed", "failed", "pending"]),
+ error: z.string().optional(),
+ metadata: z.record(z.unknown()).optional(),
+ finalStatus: z.enum(["done", "failed"]).optional(),
+})
+export type ProcessingStep = z.infer<typeof ProcessingStepSchema>
+
+export const ProcessingMetadataSchema = z.object({
+ startTime: z.number(),
+ endTime: z.number().optional(),
+ duration: z.number().optional(),
+ error: z.string().optional(),
+ finalStatus: z.enum(["completed", "failed", "done"]).optional(),
+ chunkingStrategy: z.string().optional(),
+ tokenCount: z.number().optional(),
+ steps: z.array(ProcessingStepSchema),
+})
+export type ProcessingMetadata = z.infer<typeof ProcessingMetadataSchema>
+
+export const DocumentSchema = z.object({
+ id: z.string(),
+ customId: z.string().nullable().optional(),
+ contentHash: z.string().nullable().optional(),
+
+ // Organization and ownership
+ orgId: z.string(),
+ userId: z.string(),
+ connectionId: z.string().nullable().optional(),
+
+ // Content fields
+ title: z.string().nullable().optional(),
+ content: z.string().nullable().optional(),
+ summary: z.string().nullable().optional(),
+ url: z.string().nullable().optional(),
+ source: z.string().nullable().optional(),
+ type: DocumentTypeEnum.default("text"),
+ status: DocumentStatusEnum.default("unknown"),
+
+ // Metadata and processing
+ metadata: MetadataSchema.nullable().optional(),
+ processingMetadata: ProcessingMetadataSchema.nullable().optional(),
+ raw: z.any().nullable().optional(), // bytea in DB
+ ogImage: z.string().nullable().optional(),
+
+ // Content statistics
+ tokenCount: z.number().nullable().optional(),
+ wordCount: z.number().nullable().optional(),
+ chunkCount: z.number().default(0),
+ averageChunkSize: z.number().nullable().optional(),
+
+ summaryEmbedding: z.array(z.number()).nullable().optional(),
+ summaryEmbeddingModel: z.string().nullable().optional(),
+ summaryEmbeddingNew: z.array(z.number()).nullable().optional(),
+ summaryEmbeddingModelNew: z.string().nullable().optional(),
+
+ // Timestamps
+ createdAt: z.coerce.date(),
+ updatedAt: z.coerce.date(),
+})
+export type Document = z.infer<typeof DocumentSchema>
+
+export const ChunkTypeEnum = z.enum(["text", "image"])
+export type ChunkType = z.infer<typeof ChunkTypeEnum>
+
+export const ChunkSchema = z.object({
+ id: z.string(),
+ documentId: z.string(),
+ content: z.string(),
+ embeddedContent: z.string().nullable().optional(),
+ type: ChunkTypeEnum.default("text"),
+ position: z.number(),
+ metadata: MetadataSchema.nullable().optional(),
+
+ embedding: z.array(z.number()).nullable().optional(),
+ embeddingModel: z.string().nullable().optional(),
+ embeddingNew: z.array(z.number()).nullable().optional(),
+ embeddingNewModel: z.string().nullable().optional(),
+ matryokshaEmbedding: z.array(z.number()).nullable().optional(),
+ matryokshaEmbeddingModel: z.string().nullable().optional(),
+
+ createdAt: z.coerce.date(),
+})
+export type Chunk = z.infer<typeof ChunkSchema>
+
+export const ConnectionProviderEnum = z.enum([
+ "notion",
+ "google-drive",
+ "onedrive",
+])
+export type ConnectionProvider = z.infer<typeof ConnectionProviderEnum>
+
+export const ConnectionStateSchema = z.object({
+ stateToken: z.string(),
+ provider: ConnectionProviderEnum,
+ orgId: z.string(),
+ userId: z.string(),
+ connectionId: z.string(),
+ documentLimit: z.number().default(10000),
+ redirectUrl: z.string().nullable().optional(),
+ metadata: MetadataSchema,
+ containerTags: z.array(z.string()).nullable().optional(),
+ createdAt: z.coerce.date(),
+ expiresAt: z.coerce.date().nullable().optional(),
+})
+export type ConnectionState = z.infer<typeof ConnectionStateSchema>
+
+export const ConnectionSchema = z.object({
+ id: z.string(),
+ provider: ConnectionProviderEnum,
+ orgId: z.string(),
+ userId: z.string(),
+ email: z.string().nullable().optional(),
+ documentLimit: z.number().default(10000),
+ containerTags: z.array(z.string()).nullable().optional(),
+
+ // Token management
+ accessToken: z.string().nullable().optional(),
+ refreshToken: z.string().nullable().optional(),
+ expiresAt: z.coerce.date().nullable().optional(),
+
+ // Provider-specific metadata
+ metadata: z.record(z.unknown()),
+
+ createdAt: z.coerce.date(),
+})
+export type Connection = z.infer<typeof ConnectionSchema>
+
+export const RequestTypeEnum = z.enum([
+ "add",
+ "search",
+ "fast_search",
+ "request",
+ "update",
+ "delete",
+ "chat",
+ "search_v4",
+])
+export type RequestType = z.infer<typeof RequestTypeEnum>
+
+export const ApiRequestSchema = z.object({
+ id: z.string(),
+ type: RequestTypeEnum,
+ orgId: z.string(),
+ userId: z.string(),
+ keyId: z.string().nullable().optional(),
+ statusCode: z.number(),
+ duration: z.number().nullable().optional(), // duration in ms
+
+ // Request/Response data
+ input: z.record(z.unknown()).nullable().optional(),
+ output: z.record(z.unknown()).nullable().optional(),
+
+ // Token usage tracking
+ originalTokens: z.number().nullable().optional(),
+ finalTokens: z.number().nullable().optional(),
+ tokensSaved: z.number().nullable().optional(), // computed field
+
+ // Cost tracking
+ costSavedUSD: z.number().nullable().optional(),
+
+ // Chat specific fields
+ model: z.string().nullable().optional(),
+ provider: z.string().nullable().optional(),
+ conversationId: z.string().nullable().optional(),
+
+ // Flags
+ contextModified: z.boolean().default(false),
+
+ // Metadata
+ metadata: MetadataSchema.nullable().optional(),
+ origin: z.string().default("api"),
+
+ createdAt: z.coerce.date(),
+})
+export type ApiRequest = z.infer<typeof ApiRequestSchema>
+
+export const SpaceSchema = z.object({
+ id: z.string(),
+ name: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ orgId: z.string(),
+ ownerId: z.string(),
+ containerTag: z.string().nullable().optional(),
+ visibility: VisibilityEnum.default("private"),
+ isExperimental: z.boolean().default(false),
+
+ // Content indexing
+ contentTextIndex: z.record(z.unknown()).default({}), // KnowledgeBase type
+ indexSize: z.number().nullable().optional(),
+
+ metadata: MetadataSchema.nullable().optional(),
+
+ createdAt: z.coerce.date(),
+ updatedAt: z.coerce.date(),
+})
+export type Space = z.infer<typeof SpaceSchema>
+
+export const MemoryRelationEnum = z.enum([
+ "updates",
+ "extends",
+ "derives",
+])
+export type MemoryRelation = z.infer<typeof MemoryRelationEnum>
+
+export const MemoryEntrySchema = z.object({
+ id: z.string(),
+ memory: z.string(), // The actual memory content
+ spaceId: z.string(),
+ orgId: z.string(),
+ userId: z.string().nullable().optional(),
+
+ // Version control
+ version: z.number().default(1),
+ isLatest: z.boolean().default(true),
+ parentMemoryId: z.string().nullable().optional(),
+ rootMemoryId: z.string().nullable().optional(),
+
+ // Memory relationships
+ memoryRelations: z.record(MemoryRelationEnum).default({}),
+
+ // Source tracking
+ sourceCount: z.number().default(1),
+
+ // Status flags
+ isInference: z.boolean().default(false),
+ isForgotten: z.boolean().default(false),
+ forgetAfter: z.coerce.date().nullable().optional(),
+ forgetReason: z.string().nullable().optional(),
+
+ // Embeddings
+ memoryEmbedding: z.array(z.number()).nullable().optional(),
+ memoryEmbeddingModel: z.string().nullable().optional(),
+ memoryEmbeddingNew: z.array(z.number()).nullable().optional(),
+ memoryEmbeddingNewModel: z.string().nullable().optional(),
+
+ metadata: z.record(z.unknown()).nullable().optional(),
+
+ createdAt: z.coerce.date(),
+ updatedAt: z.coerce.date(),
+})
+export type MemoryEntry = z.infer<typeof MemoryEntrySchema>
+
+export const DocumentsToSpacesSchema = z.object({
+ documentId: z.string(),
+ spaceId: z.string(),
+})
+export type DocumentsToSpaces = z.infer<typeof DocumentsToSpacesSchema>
+
+export const MemoryDocumentSourceSchema = z.object({
+ memoryEntryId: z.string(),
+ documentId: z.string(),
+ relevanceScore: z.number().default(100),
+ metadata: z.record(z.unknown()).nullable().optional(),
+ addedAt: z.coerce.date(),
+})
+export type MemoryDocumentSource = z.infer<typeof MemoryDocumentSourceSchema>
+
+export const SpaceRoleEnum = z.enum([
+ "owner",
+ "admin",
+ "editor",
+ "viewer",
+])
+export type SpaceRole = z.infer<typeof SpaceRoleEnum>
+
+export const SpacesToMembersSchema = z.object({
+ spaceId: z.string(),
+ userId: z.string(),
+ role: SpaceRoleEnum.default("viewer"),
+ metadata: MetadataSchema.nullable().optional(),
+ createdAt: z.coerce.date(),
+ updatedAt: z.coerce.date(),
+})
+export type SpacesToMembers = z.infer<typeof SpacesToMembersSchema>
+
+export const OrganizationSettingsSchema = z.object({
+ id: z.string(),
+ orgId: z.string(),
+
+ // LLM Filtering
+ shouldLLMFilter: z.boolean().default(false),
+ filterPrompt: z.string().nullable().optional(),
+ includeItems: z.array(z.string()).nullable().optional(),
+ excludeItems: z.array(z.string()).nullable().optional(),
+
+ // Google Drive custom keys
+ googleDriveCustomKeyEnabled: z.boolean().default(false),
+ googleDriveClientId: z.string().nullable().optional(),
+ googleDriveClientSecret: z.string().nullable().optional(),
+
+ // Notion custom keys
+ notionCustomKeyEnabled: z.boolean().default(false),
+ notionClientId: z.string().nullable().optional(),
+ notionClientSecret: z.string().nullable().optional(),
+
+ // OneDrive custom keys
+ onedriveCustomKeyEnabled: z.boolean().default(false),
+ onedriveClientId: z.string().nullable().optional(),
+ onedriveClientSecret: z.string().nullable().optional(),
+
+ updatedAt: z.coerce.date(),
+})
+export type OrganizationSettings = z.infer<typeof OrganizationSettingsSchema>
+
+export const schemas = {
+ // Base types
+ MetadataSchema,
+ VisibilityEnum,
+
+ // Content
+ DocumentTypeEnum,
+ DocumentStatusEnum,
+ ProcessingStepSchema,
+ ProcessingMetadataSchema,
+ DocumentSchema,
+ ChunkTypeEnum,
+ ChunkSchema,
+
+ // Connections
+ ConnectionProviderEnum,
+ ConnectionStateSchema,
+ ConnectionSchema,
+
+ // Analytics
+ RequestTypeEnum,
+ ApiRequestSchema,
+
+ // Spaces and Memory
+ SpaceSchema,
+ MemoryRelationEnum,
+ MemoryEntrySchema,
+ DocumentsToSpacesSchema,
+ MemoryDocumentSourceSchema,
+ SpaceRoleEnum,
+ SpacesToMembersSchema,
+
+ // Auth
+ OrganizationSettingsSchema,
+} as const \ No newline at end of file
diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json
new file mode 100644
index 00000000..4acb0fd3
--- /dev/null
+++ b/packages/validation/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@total-typescript/tsconfig/bundler/no-dom/library-monorepo",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ }
+}